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 (
sessionCookieName = "satoru_session"
sessionTTL = 24 * time.Hour * 14
sessionTTL = 24 * time.Hour * 7
scanInterval = 24 * time.Hour
scanLoopTick = time.Hour
)
type app struct {
@ -48,6 +50,11 @@ func main() {
defer st.Close()
a := &app{store: st}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go a.startSiteScanLoop(ctx)
r := chi.NewRouter()
fileServer := http.FileServer(http.Dir("web/static"))
@ -72,7 +79,7 @@ func main() {
}
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 {
templ.Handler(webui.Home(time.Now(), store.User{})).ServeHTTP(w, r)
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) {
if _, err := a.currentUser(r.Context(), r); err == nil {
if _, err := a.currentUserWithRollingSession(w, r); err == nil {
http.Redirect(w, r, "/", http.StatusSeeOther)
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) {
if _, err := a.currentUser(r.Context(), r); err == nil {
if _, err := a.currentUserWithRollingSession(w, r); err == nil {
http.Redirect(w, r, "/", http.StatusSeeOther)
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) {
user, err := a.currentUser(r.Context(), r)
user, err := a.currentUserWithRollingSession(w, r)
if err != nil {
http.Redirect(w, r, "/signin", http.StatusSeeOther)
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) {
user, err := a.currentUser(r.Context(), r)
user, err := a.currentUserWithRollingSession(w, r)
if err != nil {
http.Redirect(w, r, "/signin", http.StatusSeeOther)
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) {
if _, err := a.currentUser(r.Context(), r); err != nil {
if _, err := a.currentUserWithRollingSession(w, r); err != nil {
http.Redirect(w, r, "/signin", http.StatusSeeOther)
return
}
@ -268,21 +275,36 @@ func (a *app) handleSiteCreate(w http.ResponseWriter, r *http.Request) {
sshUser := strings.TrimSpace(r.FormValue("ssh_user"))
host := strings.TrimSpace(r.FormValue("host"))
remotePath := strings.TrimSpace(r.FormValue("remote_path"))
if sshUser == "" || host == "" || remotePath == "" {
port, err := parsePort(r.FormValue("port"))
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)
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)
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)
}
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)
return
}
@ -322,6 +344,11 @@ func (a *app) issueSession(w http.ResponseWriter, r *http.Request, userID int64)
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{
Name: sessionCookieName,
Value: token,
@ -331,7 +358,6 @@ func (a *app) issueSession(w http.ResponseWriter, r *http.Request, userID int64)
Secure: r.TLS != nil,
Expires: expiresAt,
})
return nil
}
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)
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) {
@ -396,7 +441,7 @@ func runSSHHello(ctx context.Context, site store.Site) (string, string) {
cmdCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
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()
output := strings.TrimSpace(string(out))
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."},
}
}
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
SSHUser string
Host string
RemotePath string
Port int
CreatedAt time.Time
LastRunStatus sql.NullString
LastRunOutput sql.NullString
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) {
@ -80,48 +92,37 @@ 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,
port INTEGER NOT NULL DEFAULT 22,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_run_status 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 {
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 {
if _, err := s.db.ExecContext(ctx, siteTargetsSQL); err != nil {
return err
}
return nil
@ -203,19 +204,39 @@ func (s *Store) DeleteSessionByTokenHash(ctx context.Context, tokenHash string)
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 {
_, 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,
)
func (s *Store) CreateSite(ctx context.Context, sshUser, host string, port int, targets []SiteTarget) (Site, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return Site{}, err
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx, `INSERT INTO sites (ssh_user, host, port) VALUES (?, ?, ?)`, sshUser, host, port)
if err != nil {
return Site{}, err
}
@ -223,12 +244,28 @@ func (s *Store) CreateSite(ctx context.Context, sshUser, host, remotePath string
if err != nil {
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)
}
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
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
ORDER BY id DESC`
rows, err := s.db.QueryContext(ctx, q)
@ -248,15 +285,27 @@ ORDER BY id DESC`
if err := rows.Err(); err != nil {
return nil, err
}
if err := s.populateTargets(ctx, out); 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
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
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 {
@ -271,6 +320,42 @@ func (s *Store) UpdateSiteRunResult(ctx context.Context, id int64, status, outpu
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) {
const q = `
SELECT id, username, password_hash, is_admin, created_at
@ -299,17 +384,79 @@ func scanSite(row scanner) (Site, error) {
&site.ID,
&site.SSHUser,
&site.Host,
&site.RemotePath,
&site.Port,
&site.CreatedAt,
&site.LastRunStatus,
&site.LastRunOutput,
&site.LastRunAt,
&site.LastScanAt,
&site.LastScanState,
&site.LastScanNotes,
); err != nil {
return Site{}, err
}
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 {
if v {
return 1
@ -323,28 +470,23 @@ 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
func nullInt64Arg(v sql.NullInt64) any {
if v.Valid {
return v.Int64
}
return nil
}
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, &notNull, &dflt, &pk); err != nil {
return nil, err
func nullStringArg(v sql.NullString) any {
if v.Valid {
return v.String
}
cols[name] = true
return nil
}
if err := rows.Err(); err != nil {
return nil, err
func timeOrNil(v sql.NullTime) any {
if v.Valid {
return v.Time.UTC().Format(time.RFC3339)
}
return cols, nil
return nil
}

View File

@ -2,6 +2,7 @@ package webui
import (
"context"
"database/sql"
"fmt"
"html"
"io"
@ -76,24 +77,63 @@ func Dashboard(data DashboardData) templ.Component {
if site.LastRunOutput.Valid && strings.TrimSpace(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(`
<article class="site-card">
<div class="site-head">
<h3>%s@%s</h3>
<h3>%s@%s:%d</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">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">Status: <span class="pill %s">%s</span></p>
<pre class="output">%s</pre>
</article>`,
html.EscapeString(site.SSHUser),
html.EscapeString(site.Host),
site.Port,
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(runStatus),
html.EscapeString(runStatus),
@ -138,10 +178,12 @@ func Dashboard(data DashboardData) templ.Component {
</section>
<section>
<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>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>
</form>
</section>
@ -170,10 +212,60 @@ func formatFlash(code string) string {
case "site-ran":
return "Run completed."
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":
return "Password updated."
default:
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;
}
.grid-2 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 0.75rem;
align-items: end;
}
.site-card {
border: 1px solid var(--border);
border-radius: 12px;
@ -210,6 +217,28 @@ h2 {
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 {
white-space: pre-wrap;
overflow-wrap: anywhere;
@ -220,11 +249,25 @@ h2 {
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 {
margin: 0.75rem 0 0;
color: var(--muted);
}
.muted.inline {
margin-left: 0.5rem;
}
a {
color: #7dd3fc;
}