diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6146c5f --- /dev/null +++ b/.gitignore @@ -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 diff --git a/cmd/satoru/main.go b/cmd/satoru/main.go index f60b6d4..437a1fa 100644 --- a/cmd/satoru/main.go +++ b/cmd/satoru/main.go @@ -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 +} diff --git a/internal/store/store.go b/internal/store/store.go index dc8c5a4..d74f18b 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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 } - 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 + return nil +} + +func nullStringArg(v sql.NullString) any { + if v.Valid { + return v.String + } + return nil +} + +func timeOrNil(v sql.NullTime) any { + if v.Valid { + return v.Time.UTC().Format(time.RFC3339) + } + return nil } diff --git a/internal/webui/dashboard.go b/internal/webui/dashboard.go index 4501a7d..e6bb653 100644 --- a/internal/webui/dashboard.go +++ b/internal/webui/dashboard.go @@ -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(`
  • none (no targets)
  • `) + } + 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(`
  • %s %s%s
  • `, + targetModeClass(t.Mode), + html.EscapeString(targetModeLabel(t.Mode)), + html.EscapeString(t.Path), + html.EscapeString(sizeOrErr))) + } sites.WriteString(fmt.Sprintf(`
    -

    %s@%s

    +

    %s@%s:%d

    -

    Path: %s

    +

    Backup targets:

    + +

    Scan: %s · Last: %s · Next: %s

    +

    %s

    Last run: %s

    Status: %s

    %s
    `, 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 {

    Add Site

    -
    + - + + +
    @@ -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) +} diff --git a/web/static/app.css b/web/static/app.css index a209e87..3303949 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -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; }