bones
This commit is contained in:
parent
3d302ee541
commit
ff2cc25cb0
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, ¬Null, &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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 /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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue