diff --git a/.gitignore b/.gitignore index 36dc539..0954a6f 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ data/ .env.* !.env.example backups/ +repos/ diff --git a/cmd/satoru/backup_job.go b/cmd/satoru/backup_job.go index c2b2463..3999e2e 100644 --- a/cmd/satoru/backup_job.go +++ b/cmd/satoru/backup_job.go @@ -67,6 +67,8 @@ func (a *app) runBackupJob(ctx context.Context, job store.Job, site store.Site) err = a.pullDirectoryTarget(ctx, job.ID, site, target, stageDir) case "sqlite_dump": err = pullSQLiteTarget(ctx, job.ID, site, target, stageDir) + case "mysql_dump": + err = pullMySQLTarget(ctx, site, target, stageDir) default: err = fmt.Errorf("unknown target mode: %s", target.Mode) } @@ -106,7 +108,10 @@ func (a *app) runBackupJob(ctx context.Context, job store.Job, site store.Site) func (a *app) pullDirectoryTarget(ctx context.Context, jobID int64, site store.Site, target store.SiteTarget, stageDir string) error { sshCmd := fmt.Sprintf("ssh -p %d", site.Port) - remote := fmt.Sprintf("%s@%s:%s/", site.SSHUser, site.Host, target.Path) + remote, err := syncRemotePath(ctx, site, target.Path) + if err != nil { + return err + } cmdCtx, cancel := context.WithTimeout(ctx, 10*time.Minute) defer cancel() @@ -173,6 +178,44 @@ func (a *app) pullDirectoryTarget(ctx context.Context, jobID int64, site store.S return nil } +func syncRemotePath(ctx context.Context, site store.Site, path string) (string, error) { + pathType, err := remotePathType(ctx, site, path) + if err != nil { + return "", err + } + switch pathType { + case "dir": + return fmt.Sprintf("%s@%s:%s/", site.SSHUser, site.Host, path), nil + case "file": + return fmt.Sprintf("%s@%s:%s", site.SSHUser, site.Host, path), nil + default: + return "", errors.New("target path is neither a directory nor a file") + } +} + +func remotePathType(ctx context.Context, site store.Site, path string) (string, error) { + target := fmt.Sprintf("%s@%s", site.SSHUser, site.Host) + cmdCtx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + + quoted := shellQuote(path) + remoteCmd := fmt.Sprintf("if [ -d %s ]; then echo dir; elif [ -f %s ]; then echo file; else echo missing; fi", quoted, quoted) + cmd := exec.CommandContext(cmdCtx, "ssh", "-p", strconv.Itoa(site.Port), target, remoteCmd) + out, err := cmd.CombinedOutput() + if err != nil { + msg := strings.TrimSpace(string(out)) + if msg == "" { + msg = err.Error() + } + return "", errors.New(msg) + } + v := strings.TrimSpace(string(out)) + if v == "" || v == "missing" { + return "", errors.New("target path not found") + } + return v, nil +} + func pullSQLiteTarget(ctx context.Context, jobID int64, site store.Site, target store.SiteTarget, stageDir string) error { tmpBase := fmt.Sprintf("/tmp/satoru-backup-%d-%s.sqlite3", jobID, shortHash(target.Path)) quotedDB := shellQuote(target.Path) @@ -201,6 +244,36 @@ func pullSQLiteTarget(ctx context.Context, jobID int64, site store.Site, target return nil } +func pullMySQLTarget(ctx context.Context, site store.Site, target store.SiteTarget, stageDir string) error { + if !target.MySQLHost.Valid || !target.MySQLUser.Valid || !target.MySQLDB.Valid || !target.MySQLPassword.Valid { + return errors.New("mysql target missing db host/db user/db name/db password") + } + + tmpBase := fmt.Sprintf("/tmp/satoru-mysql-%s.sql", shortHash(target.Path+target.MySQLHost.String+target.MySQLDB.String)) + remoteCmd := strings.Join([]string{ + mysqlDumpCommand(target, false, tmpBase), + fmt.Sprintf("gzip -f -- %s", shellQuote(tmpBase)), + }, " && ") + if err := sshCheck(ctx, site, remoteCmd); err != nil { + _ = sshCheck(ctx, site, fmt.Sprintf("rm -f -- %s %s", shellQuote(tmpBase), shellQuote(tmpBase+".gz"))) + return err + } + + sshCmd := fmt.Sprintf("ssh -p %d", site.Port) + remoteGz := fmt.Sprintf("%s@%s:%s", site.SSHUser, site.Host, tmpBase+".gz") + localFile := filepath.Join(stageDir, "mysql-dump.sql.gz") + + cmdCtx, cancel := context.WithTimeout(ctx, 10*time.Minute) + defer cancel() + cmd := exec.CommandContext(cmdCtx, "rsync", "-a", "-e", sshCmd, remoteGz, localFile) + out, err := cmd.CombinedOutput() + _ = sshCheck(ctx, site, fmt.Sprintf("rm -f -- %s %s", shellQuote(tmpBase), shellQuote(tmpBase+".gz"))) + if err != nil { + return errors.New(strings.TrimSpace(string(out))) + } + return nil +} + func runResticBackup(ctx context.Context, repoPath string, site store.Site, jobID int64, stagedPaths []string) error { if err := ensureResticRepo(ctx, repoPath); err != nil { return err @@ -373,14 +446,30 @@ func latestSnapshotIDForSite(ctx context.Context, repoPath, siteUUID string) (st } func targetStageDir(root, siteUUID string, target store.SiteTarget) string { - hash := hashPath(target.Path) + hash := hashPath(targetIdentity(target)) modeDir := "dir" if target.Mode == "sqlite_dump" { modeDir = "sqlite" + } else if target.Mode == "mysql_dump" { + modeDir = "mysql" } return filepath.Join(root, siteUUID, hash, modeDir) } +func targetIdentity(target store.SiteTarget) string { + parts := []string{target.Mode, target.Path} + if target.MySQLHost.Valid { + parts = append(parts, target.MySQLHost.String) + } + if target.MySQLUser.Valid { + parts = append(parts, target.MySQLUser.String) + } + if target.MySQLDB.Valid { + parts = append(parts, target.MySQLDB.String) + } + return strings.Join(parts, "|") +} + func hashPath(path string) string { sum := sha256.Sum256([]byte(path)) return hex.EncodeToString(sum[:]) diff --git a/cmd/satoru/jobs.go b/cmd/satoru/jobs.go index dff148b..5cad46a 100644 --- a/cmd/satoru/jobs.go +++ b/cmd/satoru/jobs.go @@ -182,9 +182,11 @@ func (a *app) runPreflightJob(ctx context.Context, job store.Job, site store.Sit var err error switch t.Mode { case "directory": - err = sshCheck(ctx, site, fmt.Sprintf("[ -d %s ]", shellQuote(t.Path))) + err = sshCheck(ctx, site, fmt.Sprintf("[ -d %s ] || [ -f %s ]", shellQuote(t.Path), shellQuote(t.Path))) case "sqlite_dump": err = sqlitePreflightCheck(ctx, site, t.Path) + case "mysql_dump": + err = mysqlPreflightCheck(ctx, site, t) default: err = fmt.Errorf("unknown target mode: %s", t.Mode) } @@ -240,6 +242,17 @@ func sqlitePreflightCheck(ctx context.Context, site store.Site, dbPath string) e return sshCheck(ctx, site, cmd) } +func mysqlPreflightCheck(ctx context.Context, site store.Site, target store.SiteTarget) error { + if !target.MySQLHost.Valid || !target.MySQLUser.Valid || !target.MySQLDB.Valid || !target.MySQLPassword.Valid { + return errors.New("mysql target missing db host/db user/db name/db password") + } + cmd := strings.Join([]string{ + "mysqldump --version >/dev/null", + mysqlDumpCommand(target, true, ""), + }, " && ") + return sshCheck(ctx, site, cmd) +} + func (a *app) enqueuePreflightJob(ctx context.Context, siteID int64) (store.Job, error) { job, err := a.store.CreateJob(ctx, siteID, jobTypePreflight) if err != nil { diff --git a/cmd/satoru/main.go b/cmd/satoru/main.go index 6858f70..b7bcc9e 100644 --- a/cmd/satoru/main.go +++ b/cmd/satoru/main.go @@ -80,9 +80,11 @@ func main() { r.Post("/account/password", a.handlePasswordSubmit) r.Post("/sites", a.handleSiteCreate) r.Post("/sites/{id}/run", a.handleSiteRun) + r.Post("/sites/{id}/mysql-dumps", a.handleSiteAddMySQLDump) r.Post("/sites/{id}/cancel", a.handleSiteCancel) r.Post("/sites/{id}/restart", a.handleSiteRestart) r.Post("/jobs/{id}/cancel", a.handleJobCancel) + r.Post("/sites/{id}/targets/{targetID}/delete", a.handleSiteTargetDelete) r.Post("/sites/{id}/update", a.handleSiteUpdate) r.Post("/sites/{id}/delete", a.handleSiteDelete) r.Get("/signup", a.handleSignupPage) @@ -353,6 +355,46 @@ func (a *app) handleSiteRun(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/?msg=job-queued", http.StatusSeeOther) } +func (a *app) handleSiteAddMySQLDump(w http.ResponseWriter, r *http.Request) { + if _, err := a.currentUserWithRollingSession(w, r); err != nil { + http.Redirect(w, r, "/signin", http.StatusSeeOther) + return + } + siteID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + http.Error(w, "invalid site id", http.StatusBadRequest) + return + } + if _, err := a.store.SiteByID(r.Context(), siteID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + http.NotFound(w, r) + return + } + http.Error(w, "failed to load site", http.StatusInternalServerError) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + + dbHost := strings.TrimSpace(r.FormValue("db_host")) + dbUser := strings.TrimSpace(r.FormValue("db_user")) + dbName := strings.TrimSpace(r.FormValue("db_name")) + dbPassword := r.FormValue("db_password") + if dbHost == "" || dbUser == "" || dbName == "" || dbPassword == "" { + http.Redirect(w, r, "/?msg=mysql-invalid", http.StatusSeeOther) + return + } + + if err := a.store.AddMySQLDumpTarget(r.Context(), siteID, dbHost, dbUser, dbName, dbPassword); err != nil { + http.Error(w, "failed to add mysql dump target", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/?msg=mysql-added", http.StatusSeeOther) +} + func (a *app) handleSiteCancel(w http.ResponseWriter, r *http.Request) { if _, err := a.currentUserWithRollingSession(w, r); err != nil { http.Redirect(w, r, "/signin", http.StatusSeeOther) @@ -437,6 +479,32 @@ func (a *app) handleJobCancel(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +func (a *app) handleSiteTargetDelete(w http.ResponseWriter, r *http.Request) { + if _, err := a.currentUserWithRollingSession(w, r); err != nil { + http.Redirect(w, r, "/signin", http.StatusSeeOther) + return + } + siteID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + http.Error(w, "invalid site id", http.StatusBadRequest) + return + } + targetID, err := strconv.ParseInt(chi.URLParam(r, "targetID"), 10, 64) + if err != nil { + http.Error(w, "invalid target id", http.StatusBadRequest) + return + } + if err := a.store.DeleteSiteTarget(r.Context(), siteID, targetID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + http.Redirect(w, r, "/?msg=target-not-found", http.StatusSeeOther) + return + } + http.Error(w, "failed to delete target", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/?msg=target-deleted", http.StatusSeeOther) +} + func (a *app) handleSiteUpdate(w http.ResponseWriter, r *http.Request) { if _, err := a.currentUserWithRollingSession(w, r); err != nil { http.Redirect(w, r, "/signin", http.StatusSeeOther) diff --git a/cmd/satoru/mysql_dump.go b/cmd/satoru/mysql_dump.go new file mode 100644 index 0000000..720f1fb --- /dev/null +++ b/cmd/satoru/mysql_dump.go @@ -0,0 +1,32 @@ +package main + +import ( + "fmt" + + "satoru/internal/store" +) + +func mysqlDumpCommand(target store.SiteTarget, noData bool, outputPath string) string { + base := fmt.Sprintf( + "mysqldump -h %s -u %s -p%s --single-transaction --quick --routines --events %s", + shellQuote(target.MySQLHost.String), + shellQuote(target.MySQLUser.String), + shellQuote(target.MySQLPassword.String), + shellQuote(target.MySQLDB.String), + ) + if noData { + return base + " --no-data >/dev/null" + } + return base + " > " + shellQuote(outputPath) +} + +func mysqlStatusCommand(target store.SiteTarget) string { + return fmt.Sprintf( + "mysql --version >/dev/null && mysql -h %s -u %s -p%s --connect-timeout=5 -e %s %s >/dev/null", + shellQuote(target.MySQLHost.String), + shellQuote(target.MySQLUser.String), + shellQuote(target.MySQLPassword.String), + shellQuote("SELECT 1"), + shellQuote(target.MySQLDB.String), + ) +} diff --git a/cmd/satoru/scanner.go b/cmd/satoru/scanner.go index 8d7228c..ba5fec1 100644 --- a/cmd/satoru/scanner.go +++ b/cmd/satoru/scanner.go @@ -136,6 +136,10 @@ func (a *app) scanSiteNow(ctx context.Context, siteID int64) { } func queryTargetSize(ctx context.Context, site store.Site, target store.SiteTarget) (int64, error) { + if target.Mode == "mysql_dump" { + return queryMySQLTargetStatus(ctx, site, target) + } + targetAddr := fmt.Sprintf("%s@%s", site.SSHUser, site.Host) cmdCtx, cancel := context.WithTimeout(ctx, 20*time.Second) defer cancel() @@ -157,11 +161,34 @@ func queryTargetSize(ctx context.Context, site store.Site, target store.SiteTarg return size, nil } +func queryMySQLTargetStatus(ctx context.Context, site store.Site, target store.SiteTarget) (int64, error) { + if !target.MySQLHost.Valid || !target.MySQLUser.Valid || !target.MySQLDB.Valid || !target.MySQLPassword.Valid { + return 0, errors.New("mysql target missing db host/db user/db name/db password") + } + + targetAddr := fmt.Sprintf("%s@%s", site.SSHUser, site.Host) + cmdCtx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + cmd := exec.CommandContext(cmdCtx, "ssh", "-p", strconv.Itoa(site.Port), targetAddr, mysqlStatusCommand(target)) + out, err := cmd.CombinedOutput() + if err != nil { + msg := strings.TrimSpace(string(out)) + if msg == "" { + msg = err.Error() + } + return 0, errors.New(msg) + } + return 0, nil +} + func remoteSizeCommand(target store.SiteTarget) string { path := shellQuote(target.Path) if target.Mode == "sqlite_dump" { return fmt.Sprintf("stat -c%%s -- %s", path) } + if target.Mode == "mysql_dump" { + return "" + } return fmt.Sprintf("du -sb -- %s | awk '{print $1}'", path) } diff --git a/internal/store/migrations.go b/internal/store/migrations.go index 91b19b7..ec6a4b3 100644 --- a/internal/store/migrations.go +++ b/internal/store/migrations.go @@ -63,7 +63,11 @@ CREATE TABLE IF NOT EXISTS schema_migrations ( 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')), + mode TEXT NOT NULL CHECK(mode IN ('directory', 'sqlite_dump', 'mysql_dump')), + mysql_host TEXT, + mysql_user TEXT, + mysql_db TEXT, + mysql_password TEXT, last_size_bytes INTEGER, last_scan_at DATETIME, last_error TEXT, @@ -193,6 +197,86 @@ CREATE TABLE IF NOT EXISTS schema_migrations ( return nil }, }, + { + version: 5, + name: "site_targets_mysql_dump", + up: func(ctx context.Context, tx *sql.Tx) error { + exists, err := tableExists(ctx, tx, "site_targets") + if err != nil { + return err + } + if !exists { + return nil + } + + if _, err := tx.ExecContext(ctx, `CREATE TABLE site_targets_new ( + 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', 'mysql_dump')), + mysql_host TEXT, + mysql_user TEXT, + mysql_db TEXT, + mysql_password TEXT, + last_size_bytes INTEGER, + last_scan_at DATETIME, + last_error TEXT, + UNIQUE(site_id, path, mode) + )`); err != nil { + return err + } + + hasMySQLHost, err := columnExists(ctx, tx, "site_targets", "mysql_host") + if err != nil { + return err + } + if hasMySQLHost { + if _, err := tx.ExecContext(ctx, ` +INSERT INTO site_targets_new (id, site_id, path, mode, mysql_host, mysql_user, mysql_db, mysql_password, last_size_bytes, last_scan_at, last_error) +SELECT id, site_id, path, mode, mysql_host, NULL, mysql_db, mysql_password, last_size_bytes, last_scan_at, last_error +FROM site_targets`); err != nil { + return err + } + } else { + if _, err := tx.ExecContext(ctx, ` +INSERT INTO site_targets_new (id, site_id, path, mode, mysql_host, mysql_user, mysql_db, mysql_password, last_size_bytes, last_scan_at, last_error) +SELECT id, site_id, path, mode, NULL, NULL, NULL, NULL, last_size_bytes, last_scan_at, last_error +FROM site_targets`); err != nil { + return err + } + } + + if _, err := tx.ExecContext(ctx, `DROP TABLE site_targets`); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `ALTER TABLE site_targets_new RENAME TO site_targets`); err != nil { + return err + } + return nil + }, + }, + { + version: 6, + name: "site_targets_mysql_user", + up: func(ctx context.Context, tx *sql.Tx) error { + exists, err := tableExists(ctx, tx, "site_targets") + if err != nil { + return err + } + if !exists { + return nil + } + hasUser, err := columnExists(ctx, tx, "site_targets", "mysql_user") + if err != nil { + return err + } + if hasUser { + return nil + } + _, err = tx.ExecContext(ctx, `ALTER TABLE site_targets ADD COLUMN mysql_user TEXT`) + return err + }, + }, } for _, m := range migrations { diff --git a/internal/store/store.go b/internal/store/store.go index 2820aa9..3d39467 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -46,11 +46,16 @@ type Site struct { } type SiteTarget struct { - Path string - Mode string - LastSizeByte sql.NullInt64 - LastScanAt sql.NullTime - LastError sql.NullString + ID int64 + Path string + Mode string + MySQLHost sql.NullString + MySQLUser sql.NullString + MySQLDB sql.NullString + MySQLPassword sql.NullString + LastSizeByte sql.NullInt64 + LastScanAt sql.NullTime + LastError sql.NullString } type Job struct { @@ -247,10 +252,14 @@ func (s *Store) CreateSite(ctx context.Context, sshUser, host string, port int, for _, t := range targets { if _, err := tx.ExecContext( ctx, - `INSERT INTO site_targets (site_id, path, mode) VALUES (?, ?, ?)`, + `INSERT INTO site_targets (site_id, path, mode, mysql_host, mysql_user, mysql_db, mysql_password) VALUES (?, ?, ?, ?, ?, ?, ?)`, id, t.Path, t.Mode, + nullStringArg(t.MySQLHost), + nullStringArg(t.MySQLUser), + nullStringArg(t.MySQLDB), + nullStringArg(t.MySQLPassword), ); err != nil { return Site{}, err } @@ -293,10 +302,14 @@ func (s *Store) UpdateSite(ctx context.Context, id int64, sshUser, host string, for _, t := range targets { if _, err := tx.ExecContext( ctx, - `INSERT INTO site_targets (site_id, path, mode) VALUES (?, ?, ?)`, + `INSERT INTO site_targets (site_id, path, mode, mysql_host, mysql_user, mysql_db, mysql_password) VALUES (?, ?, ?, ?, ?, ?, ?)`, id, t.Path, t.Mode, + nullStringArg(t.MySQLHost), + nullStringArg(t.MySQLUser), + nullStringArg(t.MySQLDB), + nullStringArg(t.MySQLPassword), ); err != nil { return Site{}, err } @@ -333,6 +346,39 @@ func (s *Store) DeleteSite(ctx context.Context, id int64) error { return nil } +func (s *Store) DeleteSiteTarget(ctx context.Context, siteID, targetID int64) error { + res, err := s.db.ExecContext(ctx, `DELETE FROM site_targets WHERE id = ? AND site_id = ?`, targetID, siteID) + if err != nil { + return err + } + affected, err := res.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return sql.ErrNoRows + } + s.debugDB("site target deleted", zap.Int64("site_id", siteID), zap.Int64("target_id", targetID)) + return nil +} + +func (s *Store) AddMySQLDumpTarget(ctx context.Context, siteID int64, dbHost, dbUser, dbName, dbPassword string) error { + _, err := s.db.ExecContext( + ctx, + `INSERT INTO site_targets (site_id, path, mode, mysql_host, mysql_user, mysql_db, mysql_password) VALUES (?, ?, 'mysql_dump', ?, ?, ?, ?)`, + siteID, + dbName, + dbHost, + dbUser, + dbName, + dbPassword, + ) + if err == nil { + s.debugDB("mysql dump target added", zap.Int64("site_id", siteID), zap.String("db_host", dbHost), zap.String("db_user", dbUser), zap.String("db_name", dbName)) + } + return err +} + func (s *Store) ListSites(ctx context.Context) ([]Site, error) { const q = ` SELECT id, site_uuid, ssh_user, host, port, created_at, last_run_status, last_run_output, last_run_at, last_scan_at, last_scan_state, last_scan_notes @@ -705,7 +751,7 @@ func (s *Store) populateFilters(ctx context.Context, sites []Site) error { } 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` + const q = `SELECT site_id, id, path, mode, mysql_host, mysql_user, mysql_db, mysql_password, 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 @@ -716,7 +762,7 @@ func (s *Store) allTargetsBySiteID(ctx context.Context) (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 { + if err := rows.Scan(&siteID, &target.ID, &target.Path, &target.Mode, &target.MySQLHost, &target.MySQLUser, &target.MySQLDB, &target.MySQLPassword, &target.LastSizeByte, &target.LastScanAt, &target.LastError); err != nil { return nil, err } out[siteID] = append(out[siteID], target) @@ -728,7 +774,7 @@ func (s *Store) allTargetsBySiteID(ctx context.Context) (map[int64][]SiteTarget, } 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` + const q = `SELECT id, path, mode, mysql_host, mysql_user, mysql_db, mysql_password, 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 @@ -738,7 +784,7 @@ func (s *Store) targetsBySiteID(ctx context.Context, siteID int64) ([]SiteTarget 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 { + if err := rows.Scan(&target.ID, &target.Path, &target.Mode, &target.MySQLHost, &target.MySQLUser, &target.MySQLDB, &target.MySQLPassword, &target.LastSizeByte, &target.LastScanAt, &target.LastError); err != nil { return nil, err } out = append(out, target) diff --git a/internal/webui/dashboard.go b/internal/webui/dashboard.go index 2dba51f..3039bd5 100644 --- a/internal/webui/dashboard.go +++ b/internal/webui/dashboard.go @@ -99,14 +99,26 @@ func Dashboard(data DashboardData) templ.Component { sizeOrErr := "size pending" if t.LastError.Valid && t.LastError.String != "" { sizeOrErr = "error: " + t.LastError.String + } else if t.Mode == "mysql_dump" && t.LastScanAt.Valid { + sizeOrErr = "connection established" } else if t.LastSizeByte.Valid { sizeOrErr = formatBytes(t.LastSizeByte.Int64) } - targets.WriteString(fmt.Sprintf(`
%s%s%s%sBackup targets:
Filters: %s
@@ -168,6 +190,7 @@ func Dashboard(data DashboardData) templ.Component { html.EscapeString(joinTargetPaths(site.Targets, "directory")), html.EscapeString(joinTargetPaths(site.Targets, "sqlite_dump")), html.EscapeString(strings.Join(site.Filters, "\n")), + site.ID, targets.String(), html.EscapeString(formatFilters(site.Filters)), html.EscapeString(scanState), @@ -458,6 +481,14 @@ func formatFlash(code string) string { return "Port must be an integer between 1 and 65535." case "password-updated": return "Password updated." + case "mysql-added": + return "MySQL dump operation added." + case "mysql-invalid": + return "MySQL host, user, database, and password are required." + case "target-deleted": + return "Target removed." + case "target-not-found": + return "Target not found." default: return code } @@ -467,6 +498,9 @@ func targetModeLabel(mode string) string { if mode == "sqlite_dump" { return "sqlite dump" } + if mode == "mysql_dump" { + return "mysql dump" + } return "directory" } @@ -488,7 +522,7 @@ func formatFilters(filters []string) string { } func targetModeClass(mode string) string { - if mode == "sqlite_dump" { + if mode == "sqlite_dump" || mode == "mysql_dump" { return "sqlite" } return "ok" diff --git a/web/static/app.css b/web/static/app.css index 5d746c0..5b78ff4 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -302,6 +302,11 @@ textarea { margin-left: 0.5rem; } +.inline-form { + display: inline-block; + margin-left: 0.5rem; +} + .throbber { display: inline-block; width: 0.9rem;