This commit is contained in:
Peter Li 2026-02-07 18:49:22 -08:00
parent 3d302ee541
commit ff2cc25cb0
5 changed files with 617 additions and 86 deletions

34
.gitignore vendored Normal file
View File

@ -0,0 +1,34 @@
# macOS
.DS_Store
# Editors / IDEs
.idea/
.vscode/
*.swp
*.swo
# Go build artifacts
bin/
dist/
*.exe
*.test
*.out
coverage.out
# Go module workspace (optional local override files)
go.work
go.work.sum
# Application runtime data
data/
*.db
*.sqlite
*.sqlite3
# Logs
*.log
# Env files
.env
.env.*
!.env.example

View File

@ -29,7 +29,9 @@ import (
const ( const (
sessionCookieName = "satoru_session" sessionCookieName = "satoru_session"
sessionTTL = 24 * time.Hour * 14 sessionTTL = 24 * time.Hour * 7
scanInterval = 24 * time.Hour
scanLoopTick = time.Hour
) )
type app struct { type app struct {
@ -48,6 +50,11 @@ func main() {
defer st.Close() defer st.Close()
a := &app{store: st} a := &app{store: st}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go a.startSiteScanLoop(ctx)
r := chi.NewRouter() r := chi.NewRouter()
fileServer := http.FileServer(http.Dir("web/static")) fileServer := http.FileServer(http.Dir("web/static"))
@ -72,7 +79,7 @@ func main() {
} }
func (a *app) handleHome(w http.ResponseWriter, r *http.Request) { func (a *app) handleHome(w http.ResponseWriter, r *http.Request) {
user, err := a.currentUser(r.Context(), r) user, err := a.currentUserWithRollingSession(w, r)
if err != nil { if err != nil {
templ.Handler(webui.Home(time.Now(), store.User{})).ServeHTTP(w, r) templ.Handler(webui.Home(time.Now(), store.User{})).ServeHTTP(w, r)
return return
@ -96,7 +103,7 @@ func (a *app) handleHome(w http.ResponseWriter, r *http.Request) {
} }
func (a *app) handleSignupPage(w http.ResponseWriter, r *http.Request) { func (a *app) handleSignupPage(w http.ResponseWriter, r *http.Request) {
if _, err := a.currentUser(r.Context(), r); err == nil { if _, err := a.currentUserWithRollingSession(w, r); err == nil {
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
return return
} }
@ -150,7 +157,7 @@ func (a *app) handleSignupSubmit(w http.ResponseWriter, r *http.Request) {
} }
func (a *app) handleSigninPage(w http.ResponseWriter, r *http.Request) { func (a *app) handleSigninPage(w http.ResponseWriter, r *http.Request) {
if _, err := a.currentUser(r.Context(), r); err == nil { if _, err := a.currentUserWithRollingSession(w, r); err == nil {
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
return return
} }
@ -203,7 +210,7 @@ func (a *app) handleSignoutSubmit(w http.ResponseWriter, r *http.Request) {
} }
func (a *app) handlePasswordPage(w http.ResponseWriter, r *http.Request) { func (a *app) handlePasswordPage(w http.ResponseWriter, r *http.Request) {
user, err := a.currentUser(r.Context(), r) user, err := a.currentUserWithRollingSession(w, r)
if err != nil { if err != nil {
http.Redirect(w, r, "/signin", http.StatusSeeOther) http.Redirect(w, r, "/signin", http.StatusSeeOther)
return return
@ -212,7 +219,7 @@ func (a *app) handlePasswordPage(w http.ResponseWriter, r *http.Request) {
} }
func (a *app) handlePasswordSubmit(w http.ResponseWriter, r *http.Request) { func (a *app) handlePasswordSubmit(w http.ResponseWriter, r *http.Request) {
user, err := a.currentUser(r.Context(), r) user, err := a.currentUserWithRollingSession(w, r)
if err != nil { if err != nil {
http.Redirect(w, r, "/signin", http.StatusSeeOther) http.Redirect(w, r, "/signin", http.StatusSeeOther)
return return
@ -257,7 +264,7 @@ func (a *app) handlePasswordSubmit(w http.ResponseWriter, r *http.Request) {
} }
func (a *app) handleSiteCreate(w http.ResponseWriter, r *http.Request) { func (a *app) handleSiteCreate(w http.ResponseWriter, r *http.Request) {
if _, err := a.currentUser(r.Context(), r); err != nil { if _, err := a.currentUserWithRollingSession(w, r); err != nil {
http.Redirect(w, r, "/signin", http.StatusSeeOther) http.Redirect(w, r, "/signin", http.StatusSeeOther)
return return
} }
@ -268,21 +275,36 @@ func (a *app) handleSiteCreate(w http.ResponseWriter, r *http.Request) {
sshUser := strings.TrimSpace(r.FormValue("ssh_user")) sshUser := strings.TrimSpace(r.FormValue("ssh_user"))
host := strings.TrimSpace(r.FormValue("host")) host := strings.TrimSpace(r.FormValue("host"))
remotePath := strings.TrimSpace(r.FormValue("remote_path")) port, err := parsePort(r.FormValue("port"))
if sshUser == "" || host == "" || remotePath == "" { if err != nil {
http.Redirect(w, r, "/?msg=site-invalid-port", http.StatusSeeOther)
return
}
directoryPaths := parsePathList(r.FormValue("directory_paths"))
sqlitePaths := parsePathList(r.FormValue("sqlite_paths"))
targets := buildTargets(directoryPaths, sqlitePaths)
if sshUser == "" || host == "" || len(targets) == 0 {
http.Redirect(w, r, "/?msg=site-invalid", http.StatusSeeOther) http.Redirect(w, r, "/?msg=site-invalid", http.StatusSeeOther)
return return
} }
if !targetsAreValid(targets) {
http.Redirect(w, r, "/?msg=site-invalid-path", http.StatusSeeOther)
return
}
if _, err := a.store.CreateSite(r.Context(), sshUser, host, remotePath); err != nil { site, err := a.store.CreateSite(r.Context(), sshUser, host, port, targets)
if err != nil {
http.Error(w, "failed to add site", http.StatusInternalServerError) http.Error(w, "failed to add site", http.StatusInternalServerError)
return return
} }
scanCtx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
a.scanSiteNow(scanCtx, site.ID)
cancel()
http.Redirect(w, r, "/?msg=site-added", http.StatusSeeOther) http.Redirect(w, r, "/?msg=site-added", http.StatusSeeOther)
} }
func (a *app) handleSiteRun(w http.ResponseWriter, r *http.Request) { func (a *app) handleSiteRun(w http.ResponseWriter, r *http.Request) {
if _, err := a.currentUser(r.Context(), r); err != nil { if _, err := a.currentUserWithRollingSession(w, r); err != nil {
http.Redirect(w, r, "/signin", http.StatusSeeOther) http.Redirect(w, r, "/signin", http.StatusSeeOther)
return return
} }
@ -322,6 +344,11 @@ func (a *app) issueSession(w http.ResponseWriter, r *http.Request, userID int64)
return err return err
} }
setSessionCookie(w, r, token, expiresAt)
return nil
}
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresAt time.Time) {
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: sessionCookieName, Name: sessionCookieName,
Value: token, Value: token,
@ -331,7 +358,6 @@ func (a *app) issueSession(w http.ResponseWriter, r *http.Request, userID int64)
Secure: r.TLS != nil, Secure: r.TLS != nil,
Expires: expiresAt, Expires: expiresAt,
}) })
return nil
} }
func clearSessionCookie(w http.ResponseWriter) { func clearSessionCookie(w http.ResponseWriter) {
@ -346,12 +372,31 @@ func clearSessionCookie(w http.ResponseWriter) {
}) })
} }
func (a *app) currentUser(ctx context.Context, r *http.Request) (store.User, error) { func (a *app) currentUser(ctx context.Context, r *http.Request) (store.User, string, error) {
c, err := r.Cookie(sessionCookieName) c, err := r.Cookie(sessionCookieName)
if err != nil || c.Value == "" { if err != nil || c.Value == "" {
return store.User{}, http.ErrNoCookie return store.User{}, "", http.ErrNoCookie
} }
return a.store.UserBySessionTokenHash(ctx, hashToken(c.Value)) user, err := a.store.UserBySessionTokenHash(ctx, hashToken(c.Value))
if err != nil {
return store.User{}, "", err
}
return user, c.Value, nil
}
func (a *app) currentUserWithRollingSession(w http.ResponseWriter, r *http.Request) (store.User, error) {
user, token, err := a.currentUser(r.Context(), r)
if err != nil {
return store.User{}, err
}
expiresAt := time.Now().Add(sessionTTL)
if err := a.store.TouchSessionByTokenHash(r.Context(), hashToken(token), expiresAt); err != nil {
clearSessionCookie(w)
return store.User{}, err
}
setSessionCookie(w, r, token, expiresAt)
return user, nil
} }
func generateToken() (string, error) { func generateToken() (string, error) {
@ -396,7 +441,7 @@ func runSSHHello(ctx context.Context, site store.Site) (string, string) {
cmdCtx, cancel := context.WithTimeout(ctx, 10*time.Second) cmdCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel() defer cancel()
cmd := exec.CommandContext(cmdCtx, "ssh", target, "echo hello from satoru") cmd := exec.CommandContext(cmdCtx, "ssh", "-p", strconv.Itoa(site.Port), target, "echo hello from satoru")
out, err := cmd.CombinedOutput() out, err := cmd.CombinedOutput()
output := strings.TrimSpace(string(out)) output := strings.TrimSpace(string(out))
if output == "" { if output == "" {
@ -416,3 +461,178 @@ func defaultWorkflowStages() []webui.WorkflowStage {
{Title: "Audit and Recover", Description: "Each site records run output/status for operational visibility before full job history is added."}, {Title: "Audit and Recover", Description: "Each site records run output/status for operational visibility before full job history is added."},
} }
} }
func parsePathList(raw string) []string {
split := strings.FieldsFunc(raw, func(r rune) bool {
return r == '\n' || r == ',' || r == ';'
})
out := make([]string, 0, len(split))
for _, item := range split {
item = strings.TrimSpace(item)
if item == "" {
continue
}
out = append(out, item)
}
return out
}
func buildTargets(directoryPaths, sqlitePaths []string) []store.SiteTarget {
out := make([]store.SiteTarget, 0, len(directoryPaths)+len(sqlitePaths))
for _, p := range directoryPaths {
out = append(out, store.SiteTarget{Path: p, Mode: "directory"})
}
for _, p := range sqlitePaths {
out = append(out, store.SiteTarget{Path: p, Mode: "sqlite_dump"})
}
return out
}
func targetsAreValid(targets []store.SiteTarget) bool {
for _, t := range targets {
if t.Path == "" || !strings.HasPrefix(t.Path, "/") {
return false
}
}
return true
}
func parsePort(raw string) (int, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 22, nil
}
port, err := strconv.Atoi(raw)
if err != nil || port < 1 || port > 65535 {
return 0, errors.New("invalid port")
}
return port, nil
}
func (a *app) startSiteScanLoop(ctx context.Context) {
a.scanAllSites(ctx)
ticker := time.NewTicker(scanLoopTick)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
a.scanDueSites(ctx)
}
}
}
func (a *app) scanAllSites(ctx context.Context) {
sites, err := a.store.ListSites(ctx)
if err != nil {
log.Printf("scan loop: failed to list sites: %v", err)
return
}
for _, site := range sites {
a.scanSiteNow(ctx, site.ID)
}
}
func (a *app) scanDueSites(ctx context.Context) {
sites, err := a.store.ListSites(ctx)
if err != nil {
log.Printf("scan loop: failed to list sites: %v", err)
return
}
now := time.Now()
for _, site := range sites {
if site.LastScanAt.Valid && site.LastScanAt.Time.Add(scanInterval).After(now) {
continue
}
a.scanSiteNow(ctx, site.ID)
}
}
func (a *app) scanSiteNow(ctx context.Context, siteID int64) {
site, err := a.store.SiteByID(ctx, siteID)
if err != nil {
log.Printf("scan site %d: load failed: %v", siteID, err)
return
}
scannedAt := time.Now()
success := 0
failures := 0
updated := make([]store.SiteTarget, 0, len(site.Targets))
for _, target := range site.Targets {
size, outErr := queryTargetSize(ctx, site, target)
target.LastScanAt = sql.NullTime{Time: scannedAt, Valid: true}
if outErr != nil {
failures++
target.LastSizeByte = sql.NullInt64{}
target.LastError = sql.NullString{String: outErr.Error(), Valid: true}
} else {
success++
target.LastSizeByte = sql.NullInt64{Int64: size, Valid: true}
target.LastError = sql.NullString{}
}
updated = append(updated, target)
}
state := "ok"
switch {
case len(site.Targets) == 0:
state = "failed"
case failures == len(site.Targets):
state = "failed"
case failures > 0:
state = "partial"
}
notes := fmt.Sprintf("%d/%d targets scanned", success, len(site.Targets))
if err := a.store.UpdateSiteScanResult(ctx, site.ID, state, notes, scannedAt, updated); err != nil {
log.Printf("scan site %d: update failed: %v", siteID, err)
}
}
func queryTargetSize(ctx context.Context, site store.Site, target store.SiteTarget) (int64, error) {
targetAddr := fmt.Sprintf("%s@%s", site.SSHUser, site.Host)
cmdCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
remote := remoteSizeCommand(target)
cmd := exec.CommandContext(cmdCtx, "ssh", "-p", strconv.Itoa(site.Port), targetAddr, remote)
out, err := cmd.CombinedOutput()
output := strings.TrimSpace(string(out))
if err != nil {
if output == "" {
output = err.Error()
}
return 0, errors.New(output)
}
size, ok := extractLastInteger(output)
if !ok {
return 0, errors.New("empty size output")
}
return size, nil
}
func remoteSizeCommand(target store.SiteTarget) string {
path := shellQuote(target.Path)
if target.Mode == "sqlite_dump" {
return fmt.Sprintf("stat -c%%s -- %s", path)
}
return fmt.Sprintf("du -sb -- %s | awk '{print $1}'", path)
}
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
func extractLastInteger(output string) (int64, bool) {
fields := strings.Fields(output)
for i := len(fields) - 1; i >= 0; i-- {
v, err := strconv.ParseInt(fields[i], 10, 64)
if err == nil {
return v, true
}
}
return 0, false
}

View File

@ -27,11 +27,23 @@ type Site struct {
ID int64 ID int64
SSHUser string SSHUser string
Host string Host string
RemotePath string Port int
CreatedAt time.Time CreatedAt time.Time
LastRunStatus sql.NullString LastRunStatus sql.NullString
LastRunOutput sql.NullString LastRunOutput sql.NullString
LastRunAt sql.NullTime LastRunAt sql.NullTime
LastScanAt sql.NullTime
LastScanState sql.NullString
LastScanNotes sql.NullString
Targets []SiteTarget
}
type SiteTarget struct {
Path string
Mode string
LastSizeByte sql.NullInt64
LastScanAt sql.NullTime
LastError sql.NullString
} }
func Open(path string) (*Store, error) { func Open(path string) (*Store, error) {
@ -80,48 +92,37 @@ CREATE TABLE IF NOT EXISTS sites (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
ssh_user TEXT NOT NULL, ssh_user TEXT NOT NULL,
host TEXT NOT NULL, host TEXT NOT NULL,
remote_path TEXT NOT NULL, port INTEGER NOT NULL DEFAULT 22,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_run_status TEXT, last_run_status TEXT,
last_run_output TEXT, last_run_output TEXT,
last_run_at DATETIME last_run_at DATETIME,
last_scan_at DATETIME,
last_scan_state TEXT,
last_scan_notes TEXT
);`
const siteTargetsSQL = `
CREATE TABLE IF NOT EXISTS site_targets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
path TEXT NOT NULL,
mode TEXT NOT NULL CHECK(mode IN ('directory', 'sqlite_dump')),
last_size_bytes INTEGER,
last_scan_at DATETIME,
last_error TEXT
);` );`
if _, err := s.db.ExecContext(ctx, usersSQL); err != nil { if _, err := s.db.ExecContext(ctx, usersSQL); err != nil {
return err return err
} }
if err := s.migrateUsersLegacyEmail(ctx); err != nil {
return err
}
if _, err := s.db.ExecContext(ctx, sessionsSQL); err != nil { if _, err := s.db.ExecContext(ctx, sessionsSQL); err != nil {
return err return err
} }
if _, err := s.db.ExecContext(ctx, sitesSQL); err != nil { if _, err := s.db.ExecContext(ctx, sitesSQL); err != nil {
return err return err
} }
return nil if _, err := s.db.ExecContext(ctx, siteTargetsSQL); err != 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 err
} }
return nil return nil
@ -203,19 +204,39 @@ func (s *Store) DeleteSessionByTokenHash(ctx context.Context, tokenHash string)
return err return err
} }
func (s *Store) TouchSessionByTokenHash(ctx context.Context, tokenHash string, expiresAt time.Time) error {
res, err := s.db.ExecContext(
ctx,
`UPDATE sessions SET expires_at = ? WHERE token_hash = ? AND expires_at > CURRENT_TIMESTAMP`,
expiresAt.UTC().Format(time.RFC3339),
tokenHash,
)
if err != nil {
return err
}
rows, err := res.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *Store) UpdateUserPasswordHash(ctx context.Context, userID int64, passwordHash string) error { 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) _, err := s.db.ExecContext(ctx, `UPDATE users SET password_hash = ? WHERE id = ?`, passwordHash, userID)
return err return err
} }
func (s *Store) CreateSite(ctx context.Context, sshUser, host, remotePath string) (Site, error) { func (s *Store) CreateSite(ctx context.Context, sshUser, host string, port int, targets []SiteTarget) (Site, error) {
res, err := s.db.ExecContext( tx, err := s.db.BeginTx(ctx, nil)
ctx, if err != nil {
`INSERT INTO sites (ssh_user, host, remote_path) VALUES (?, ?, ?)`, return Site{}, err
sshUser, }
host, defer tx.Rollback()
remotePath,
) res, err := tx.ExecContext(ctx, `INSERT INTO sites (ssh_user, host, port) VALUES (?, ?, ?)`, sshUser, host, port)
if err != nil { if err != nil {
return Site{}, err return Site{}, err
} }
@ -223,12 +244,28 @@ func (s *Store) CreateSite(ctx context.Context, sshUser, host, remotePath string
if err != nil { if err != nil {
return Site{}, err return Site{}, err
} }
for _, t := range targets {
if _, err := tx.ExecContext(
ctx,
`INSERT INTO site_targets (site_id, path, mode) VALUES (?, ?, ?)`,
id,
t.Path,
t.Mode,
); err != nil {
return Site{}, err
}
}
if err := tx.Commit(); err != nil {
return Site{}, err
}
return s.SiteByID(ctx, id) return s.SiteByID(ctx, id)
} }
func (s *Store) ListSites(ctx context.Context) ([]Site, error) { func (s *Store) ListSites(ctx context.Context) ([]Site, error) {
const q = ` const q = `
SELECT id, ssh_user, host, remote_path, created_at, last_run_status, last_run_output, last_run_at SELECT id, ssh_user, host, port, created_at, last_run_status, last_run_output, last_run_at, last_scan_at, last_scan_state, last_scan_notes
FROM sites FROM sites
ORDER BY id DESC` ORDER BY id DESC`
rows, err := s.db.QueryContext(ctx, q) rows, err := s.db.QueryContext(ctx, q)
@ -248,15 +285,27 @@ ORDER BY id DESC`
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return nil, err return nil, err
} }
if err := s.populateTargets(ctx, out); err != nil {
return nil, err
}
return out, nil return out, nil
} }
func (s *Store) SiteByID(ctx context.Context, id int64) (Site, error) { func (s *Store) SiteByID(ctx context.Context, id int64) (Site, error) {
const q = ` const q = `
SELECT id, ssh_user, host, remote_path, created_at, last_run_status, last_run_output, last_run_at SELECT id, ssh_user, host, port, created_at, last_run_status, last_run_output, last_run_at, last_scan_at, last_scan_state, last_scan_notes
FROM sites FROM sites
WHERE id = ?` WHERE id = ?`
return scanSite(s.db.QueryRowContext(ctx, q, id)) site, err := scanSite(s.db.QueryRowContext(ctx, q, id))
if err != nil {
return Site{}, err
}
targets, err := s.targetsBySiteID(ctx, id)
if err != nil {
return Site{}, err
}
site.Targets = targets
return site, nil
} }
func (s *Store) UpdateSiteRunResult(ctx context.Context, id int64, status, output string, at time.Time) error { func (s *Store) UpdateSiteRunResult(ctx context.Context, id int64, status, output string, at time.Time) error {
@ -271,6 +320,42 @@ func (s *Store) UpdateSiteRunResult(ctx context.Context, id int64, status, outpu
return err return err
} }
func (s *Store) UpdateSiteScanResult(ctx context.Context, siteID int64, state, notes string, scannedAt time.Time, targets []SiteTarget) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
for _, t := range targets {
if _, err := tx.ExecContext(
ctx,
`UPDATE site_targets SET last_size_bytes = ?, last_scan_at = ?, last_error = ? WHERE site_id = ? AND path = ? AND mode = ?`,
nullInt64Arg(t.LastSizeByte),
timeOrNil(t.LastScanAt),
nullStringArg(t.LastError),
siteID,
t.Path,
t.Mode,
); err != nil {
return err
}
}
if _, err := tx.ExecContext(
ctx,
`UPDATE sites SET last_scan_at = ?, last_scan_state = ?, last_scan_notes = ? WHERE id = ?`,
scannedAt.UTC().Format(time.RFC3339),
state,
notes,
siteID,
); err != nil {
return err
}
return tx.Commit()
}
func userByIDTx(ctx context.Context, tx *sql.Tx, id int64) (User, error) { func userByIDTx(ctx context.Context, tx *sql.Tx, id int64) (User, error) {
const q = ` const q = `
SELECT id, username, password_hash, is_admin, created_at SELECT id, username, password_hash, is_admin, created_at
@ -299,17 +384,79 @@ func scanSite(row scanner) (Site, error) {
&site.ID, &site.ID,
&site.SSHUser, &site.SSHUser,
&site.Host, &site.Host,
&site.RemotePath, &site.Port,
&site.CreatedAt, &site.CreatedAt,
&site.LastRunStatus, &site.LastRunStatus,
&site.LastRunOutput, &site.LastRunOutput,
&site.LastRunAt, &site.LastRunAt,
&site.LastScanAt,
&site.LastScanState,
&site.LastScanNotes,
); err != nil { ); err != nil {
return Site{}, err return Site{}, err
} }
return site, nil return site, nil
} }
func (s *Store) populateTargets(ctx context.Context, sites []Site) error {
if len(sites) == 0 {
return nil
}
targetsBySite, err := s.allTargetsBySiteID(ctx)
if err != nil {
return err
}
for i := range sites {
sites[i].Targets = targetsBySite[sites[i].ID]
}
return nil
}
func (s *Store) allTargetsBySiteID(ctx context.Context) (map[int64][]SiteTarget, error) {
const q = `SELECT site_id, path, mode, last_size_bytes, last_scan_at, last_error FROM site_targets ORDER BY id ASC`
rows, err := s.db.QueryContext(ctx, q)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[int64][]SiteTarget{}
for rows.Next() {
var siteID int64
var target SiteTarget
if err := rows.Scan(&siteID, &target.Path, &target.Mode, &target.LastSizeByte, &target.LastScanAt, &target.LastError); err != nil {
return nil, err
}
out[siteID] = append(out[siteID], target)
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func (s *Store) targetsBySiteID(ctx context.Context, siteID int64) ([]SiteTarget, error) {
const q = `SELECT path, mode, last_size_bytes, last_scan_at, last_error FROM site_targets WHERE site_id = ? ORDER BY id ASC`
rows, err := s.db.QueryContext(ctx, q, siteID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []SiteTarget
for rows.Next() {
var target SiteTarget
if err := rows.Scan(&target.Path, &target.Mode, &target.LastSizeByte, &target.LastScanAt, &target.LastError); err != nil {
return nil, err
}
out = append(out, target)
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func boolToInt(v bool) int { func boolToInt(v bool) int {
if v { if v {
return 1 return 1
@ -323,28 +470,23 @@ func isUniqueUsernameErr(err error) bool {
return strings.Contains(err.Error(), "UNIQUE constraint failed: users.username") return strings.Contains(err.Error(), "UNIQUE constraint failed: users.username")
} }
func tableColumns(ctx context.Context, db *sql.DB, table string) (map[string]bool, error) { func nullInt64Arg(v sql.NullInt64) any {
rows, err := db.QueryContext(ctx, fmt.Sprintf("PRAGMA table_info(%s)", table)) if v.Valid {
if err != nil { return v.Int64
return nil, err
} }
defer rows.Close() return nil
}
cols := map[string]bool{}
for rows.Next() { func nullStringArg(v sql.NullString) any {
var cid int if v.Valid {
var name string return v.String
var typ string }
var notNull int return nil
var dflt sql.NullString }
var pk int
if err := rows.Scan(&cid, &name, &typ, &notNull, &dflt, &pk); err != nil { func timeOrNil(v sql.NullTime) any {
return nil, err if v.Valid {
} return v.Time.UTC().Format(time.RFC3339)
cols[name] = true }
} return nil
if err := rows.Err(); err != nil {
return nil, err
}
return cols, nil
} }

View File

@ -2,6 +2,7 @@ package webui
import ( import (
"context" "context"
"database/sql"
"fmt" "fmt"
"html" "html"
"io" "io"
@ -76,24 +77,63 @@ func Dashboard(data DashboardData) templ.Component {
if site.LastRunOutput.Valid && strings.TrimSpace(site.LastRunOutput.String) != "" { if site.LastRunOutput.Valid && strings.TrimSpace(site.LastRunOutput.String) != "" {
runOutput = site.LastRunOutput.String runOutput = site.LastRunOutput.String
} }
scanState := "pending"
if site.LastScanState.Valid && site.LastScanState.String != "" {
scanState = site.LastScanState.String
}
lastScan := "Never"
if site.LastScanAt.Valid {
lastScan = site.LastScanAt.Time.Local().Format(time.RFC1123)
}
scanNotes := "No scan yet."
if site.LastScanNotes.Valid && site.LastScanNotes.String != "" {
scanNotes = site.LastScanNotes.String
}
var targets strings.Builder
if len(site.Targets) == 0 {
targets.WriteString(`<li><span class="pill pending">none</span> <code>(no targets)</code></li>`)
}
for _, t := range site.Targets {
sizeOrErr := "size pending"
if t.LastError.Valid && t.LastError.String != "" {
sizeOrErr = "error: " + t.LastError.String
} else if t.LastSizeByte.Valid {
sizeOrErr = formatBytes(t.LastSizeByte.Int64)
}
targets.WriteString(fmt.Sprintf(`<li><span class="pill %s">%s</span> <code>%s</code><span class="muted inline">%s</span></li>`,
targetModeClass(t.Mode),
html.EscapeString(targetModeLabel(t.Mode)),
html.EscapeString(t.Path),
html.EscapeString(sizeOrErr)))
}
sites.WriteString(fmt.Sprintf(` sites.WriteString(fmt.Sprintf(`
<article class="site-card"> <article class="site-card">
<div class="site-head"> <div class="site-head">
<h3>%s@%s</h3> <h3>%s@%s:%d</h3>
<form method="post" action="/sites/%d/run"> <form method="post" action="/sites/%d/run">
<button class="button" type="submit">Run</button> <button class="button" type="submit">Run</button>
</form> </form>
</div> </div>
<p class="muted">Path: <code>%s</code></p> <p class="muted">Backup targets:</p>
<ul class="target-list">%s</ul>
<p class="muted">Scan: <span class="pill %s">%s</span> · Last: %s · Next: %s</p>
<p class="muted">%s</p>
<p class="muted">Last run: %s</p> <p class="muted">Last run: %s</p>
<p class="muted">Status: <span class="pill %s">%s</span></p> <p class="muted">Status: <span class="pill %s">%s</span></p>
<pre class="output">%s</pre> <pre class="output">%s</pre>
</article>`, </article>`,
html.EscapeString(site.SSHUser), html.EscapeString(site.SSHUser),
html.EscapeString(site.Host), html.EscapeString(site.Host),
site.Port,
site.ID, site.ID,
html.EscapeString(site.RemotePath), targets.String(),
html.EscapeString(scanState),
html.EscapeString(scanState),
html.EscapeString(lastScan),
html.EscapeString(timeUntilNextScan(data.Now, site.LastScanAt)),
html.EscapeString(scanNotes),
html.EscapeString(last), html.EscapeString(last),
html.EscapeString(runStatus), html.EscapeString(runStatus),
html.EscapeString(runStatus), html.EscapeString(runStatus),
@ -138,10 +178,12 @@ func Dashboard(data DashboardData) templ.Component {
</section> </section>
<section> <section>
<h2>Add Site</h2> <h2>Add Site</h2>
<form class="grid-3" method="post" action="/sites"> <form class="grid-2" method="post" action="/sites">
<label class="stack"><span>SSH User</span><input name="ssh_user" required /></label> <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>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> <label class="stack"><span>Port</span><input type="number" name="port" min="1" max="65535" value="22" required /></label>
<label class="stack"><span>Directory paths (one per line)</span><textarea name="directory_paths" placeholder="/var/www&#10;/etc/nginx" rows="4"></textarea></label>
<label class="stack"><span>SQLite DB paths (will dump, one per line)</span><textarea name="sqlite_paths" placeholder="/srv/app/db/app.sqlite3" rows="4"></textarea></label>
<button class="button" type="submit">Add site</button> <button class="button" type="submit">Add site</button>
</form> </form>
</section> </section>
@ -170,10 +212,60 @@ func formatFlash(code string) string {
case "site-ran": case "site-ran":
return "Run completed." return "Run completed."
case "site-invalid": case "site-invalid":
return "All site fields are required." return "SSH user, host, and at least one target path are required."
case "site-invalid-path":
return "Target paths must be absolute Linux paths starting with '/'."
case "site-invalid-port":
return "Port must be an integer between 1 and 65535."
case "password-updated": case "password-updated":
return "Password updated." return "Password updated."
default: default:
return code return code
} }
} }
func targetModeLabel(mode string) string {
if mode == "sqlite_dump" {
return "sqlite dump"
}
return "directory"
}
func targetModeClass(mode string) string {
if mode == "sqlite_dump" {
return "sqlite"
}
return "ok"
}
func formatBytes(v int64) string {
if v < 1024 {
return fmt.Sprintf("%d B", v)
}
units := []string{"KB", "MB", "GB", "TB"}
value := float64(v)
for _, u := range units {
value /= 1024
if value < 1024 {
return fmt.Sprintf("%.1f %s", value, u)
}
}
return fmt.Sprintf("%.1f PB", value/1024)
}
func timeUntilNextScan(now time.Time, lastScan sql.NullTime) string {
if !lastScan.Valid {
return "due now"
}
next := lastScan.Time.Add(24 * time.Hour)
if !next.After(now) {
return "due now"
}
d := next.Sub(now).Round(time.Minute)
h := int(d.Hours())
m := int(d.Minutes()) % 60
if h == 0 {
return fmt.Sprintf("%dm", m)
}
return fmt.Sprintf("%dh %dm", h, m)
}

View File

@ -172,6 +172,13 @@ h2 {
align-items: end; align-items: end;
} }
.grid-2 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 0.75rem;
align-items: end;
}
.site-card { .site-card {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 12px; border-radius: 12px;
@ -210,6 +217,28 @@ h2 {
color: #fecaca; color: #fecaca;
} }
.pill.partial {
border-color: #f59e0b;
color: #fde68a;
}
.pill.sqlite {
border-color: #f59e0b;
color: #fde68a;
}
.pill.pending {
border-color: var(--border);
color: var(--muted);
}
.target-list {
margin: 0.25rem 0 0.75rem;
padding-left: 1.1rem;
display: grid;
gap: 0.3rem;
}
.output { .output {
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: anywhere; overflow-wrap: anywhere;
@ -220,11 +249,25 @@ h2 {
background: color-mix(in srgb, var(--card) 80%, black); background: color-mix(in srgb, var(--card) 80%, black);
} }
textarea {
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;
resize: vertical;
}
.muted { .muted {
margin: 0.75rem 0 0; margin: 0.75rem 0 0;
color: var(--muted); color: var(--muted);
} }
.muted.inline {
margin-left: 0.5rem;
}
a { a {
color: #7dd3fc; color: #7dd3fc;
} }