This commit is contained in:
Peter Li 2026-02-07 23:16:18 -08:00
parent 848b328359
commit cd245e4913
7 changed files with 451 additions and 7 deletions

View File

@ -69,6 +69,10 @@ func (a *app) runBackupJob(ctx context.Context, job store.Job, site store.Site)
err = pullSQLiteTarget(ctx, job.ID, site, target, stageDir)
case "mysql_dump":
err = pullMySQLTarget(ctx, site, target, stageDir)
case "podman_save":
err = pullPodmanSaveTarget(ctx, site, target, stageDir)
case "podman_export":
err = pullPodmanExportTarget(ctx, site, target, stageDir)
default:
err = fmt.Errorf("unknown target mode: %s", target.Mode)
}
@ -273,6 +277,62 @@ func pullMySQLTarget(ctx context.Context, site store.Site, target store.SiteTarg
return nil
}
func pullPodmanSaveTarget(ctx context.Context, site store.Site, target store.SiteTarget, stageDir string) error {
imageName := strings.TrimSpace(target.Path)
if imageName == "" {
return errors.New("podman target missing image name")
}
tmpTar := fmt.Sprintf("/tmp/satoru-podman-%s.tar", shortHash(targetIdentity(target)))
remoteCmd := fmt.Sprintf("podman save -o %s %s", shellQuote(tmpTar), shellQuote(imageName))
if err := sshCheck(ctx, site, remoteCmd); err != nil {
_ = sshCheck(ctx, site, fmt.Sprintf("rm -f -- %s", shellQuote(tmpTar)))
return err
}
sshCmd := fmt.Sprintf("ssh -p %d", site.Port)
remoteTar := fmt.Sprintf("%s@%s:%s", site.SSHUser, site.Host, tmpTar)
localFile := filepath.Join(stageDir, "podman-image.tar")
cmdCtx, cancel := context.WithTimeout(ctx, 20*time.Minute)
defer cancel()
cmd := exec.CommandContext(cmdCtx, "rsync", "-a", "-e", sshCmd, remoteTar, localFile)
out, err := cmd.CombinedOutput()
_ = sshCheck(ctx, site, fmt.Sprintf("rm -f -- %s", shellQuote(tmpTar)))
if err != nil {
return errors.New(strings.TrimSpace(string(out)))
}
return nil
}
func pullPodmanExportTarget(ctx context.Context, site store.Site, target store.SiteTarget, stageDir string) error {
containerName := strings.TrimSpace(target.Path)
if containerName == "" {
return errors.New("podman export target missing container name")
}
tmpTar := fmt.Sprintf("/tmp/satoru-podman-export-%s.tar", shortHash(targetIdentity(target)))
remoteCmd := fmt.Sprintf("podman export -o %s %s", shellQuote(tmpTar), shellQuote(containerName))
if err := sshCheck(ctx, site, remoteCmd); err != nil {
_ = sshCheck(ctx, site, fmt.Sprintf("rm -f -- %s", shellQuote(tmpTar)))
return err
}
sshCmd := fmt.Sprintf("ssh -p %d", site.Port)
remoteTar := fmt.Sprintf("%s@%s:%s", site.SSHUser, site.Host, tmpTar)
localFile := filepath.Join(stageDir, "podman-container.tar")
cmdCtx, cancel := context.WithTimeout(ctx, 20*time.Minute)
defer cancel()
cmd := exec.CommandContext(cmdCtx, "rsync", "-a", "-e", sshCmd, remoteTar, localFile)
out, err := cmd.CombinedOutput()
_ = sshCheck(ctx, site, fmt.Sprintf("rm -f -- %s", shellQuote(tmpTar)))
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
@ -345,13 +405,17 @@ func (a *app) runResticSyncJob(ctx context.Context, job store.Job, site store.Si
}
_ = a.store.AddJobEvent(ctx, store.JobEvent{JobID: job.ID, Level: "info", Message: "restic sync started"})
args := []string{"-r", repoPath, "copy", snapshotID, "--repo2", b2Repo}
a.log.Debug("restic sync copy", zap.Int64("job_id", job.ID), zap.Int64("site_id", site.ID), zap.String("local_repo", repoPath), zap.String("b2_repo", b2Repo), zap.String("snapshot_id", snapshotID), zap.Strings("args", args))
args, env, copyMode, err := buildResticCopyInvocation(ctx, repoPath, b2Repo, snapshotID)
if err != nil {
_ = a.store.AddJobEvent(ctx, store.JobEvent{JobID: job.ID, Level: "error", Message: "restic sync failed: " + err.Error()})
return "failed", "restic sync failed: command build error"
}
a.log.Debug("restic sync copy", zap.Int64("job_id", job.ID), zap.Int64("site_id", site.ID), zap.String("local_repo", repoPath), zap.String("b2_repo", b2Repo), zap.String("snapshot_id", snapshotID), zap.String("copy_mode", copyMode), zap.Strings("args", args))
cmdCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
cmd := exec.CommandContext(cmdCtx, "restic", args...)
cmd.Env = resticEnv()
cmd.Env = env
out, err := cmd.CombinedOutput()
if err != nil {
msg := strings.TrimSpace(string(out))
@ -366,6 +430,80 @@ func (a *app) runResticSyncJob(ctx context.Context, job store.Job, site store.Si
return "success", "restic sync completed"
}
func buildResticCopyInvocation(ctx context.Context, sourceRepo, destinationRepo, snapshotID string) ([]string, []string, string, error) {
modern, err := resticSupportsFromRepo(ctx)
if err != nil {
return nil, nil, "", err
}
if modern {
args := []string{"-r", destinationRepo, "copy", snapshotID, "--from-repo", sourceRepo}
return args, resticCopyEnvModern(), "from-repo", nil
}
args := []string{"-r", sourceRepo, "copy", snapshotID, "--repo2", destinationRepo}
return args, resticEnv(), "repo2", nil
}
func resticSupportsFromRepo(ctx context.Context) (bool, error) {
cmdCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
cmd := exec.CommandContext(cmdCtx, "restic", "copy", "--help")
out, err := cmd.CombinedOutput()
if err != nil {
msg := strings.TrimSpace(string(out))
if msg == "" {
msg = err.Error()
}
return false, errors.New(msg)
}
help := string(out)
return strings.Contains(help, "--from-repo"), nil
}
func resticCopyEnvModern() []string {
env := os.Environ()
password := configValue("RESTIC_PASSWORD")
passwordFile := configValue("RESTIC_PASSWORD_FILE")
password2 := configValue("RESTIC_PASSWORD2")
passwordFile2 := configValue("RESTIC_PASSWORD_FILE2")
b2ApplicationID := configValue("B2_APPLICATION_ID")
b2ApplicationKey := configValue("B2_APPLICATION_KEY")
b2AccountID := configValue("B2_ACCOUNT_ID")
b2AccountKey := configValue("B2_ACCOUNT_KEY")
if password == "" && passwordFile == "" {
password = configuredResticPassword()
}
if password2 == "" && passwordFile2 == "" {
if password != "" {
password2 = password
} else {
password2 = configuredResticPassword()
}
}
if password2 != "" {
env = append(env, "RESTIC_PASSWORD="+password2)
}
if passwordFile2 != "" {
env = append(env, "RESTIC_PASSWORD_FILE="+passwordFile2)
}
if password != "" {
env = append(env, "RESTIC_FROM_PASSWORD="+password)
}
if passwordFile != "" {
env = append(env, "RESTIC_FROM_PASSWORD_FILE="+passwordFile)
}
if b2AccountID == "" && b2ApplicationID != "" {
env = append(env, "B2_ACCOUNT_ID="+b2ApplicationID)
}
if b2AccountKey == "" && b2ApplicationKey != "" {
env = append(env, "B2_ACCOUNT_KEY="+b2ApplicationKey)
}
return env
}
func ensureResticRepo(ctx context.Context, repoPath string) error {
check := exec.CommandContext(ctx, "restic", "-r", repoPath, "cat", "config")
check.Env = resticEnv()
@ -470,6 +608,10 @@ func targetStageDir(root, siteUUID string, target store.SiteTarget) string {
modeDir = "sqlite"
} else if target.Mode == "mysql_dump" {
modeDir = "mysql"
} else if target.Mode == "podman_save" {
modeDir = "podman"
} else if target.Mode == "podman_export" {
modeDir = "podman-export"
}
return filepath.Join(root, siteUUID, hash, modeDir)
}

View File

@ -189,6 +189,10 @@ func (a *app) runPreflightJob(ctx context.Context, job store.Job, site store.Sit
err = sqlitePreflightCheck(ctx, site, t.Path)
case "mysql_dump":
err = mysqlPreflightCheck(ctx, site, t)
case "podman_save":
err = podmanSavePreflightCheck(ctx, site, t)
case "podman_export":
err = podmanExportPreflightCheck(ctx, site, t)
default:
err = fmt.Errorf("unknown target mode: %s", t.Mode)
}
@ -255,6 +259,24 @@ func mysqlPreflightCheck(ctx context.Context, site store.Site, target store.Site
return sshCheck(ctx, site, cmd)
}
func podmanSavePreflightCheck(ctx context.Context, site store.Site, target store.SiteTarget) error {
imageName := strings.TrimSpace(target.Path)
if imageName == "" {
return errors.New("podman target missing image name")
}
cmd := fmt.Sprintf("podman --version >/dev/null && podman image exists %s", shellQuote(imageName))
return sshCheck(ctx, site, cmd)
}
func podmanExportPreflightCheck(ctx context.Context, site store.Site, target store.SiteTarget) error {
containerName := strings.TrimSpace(target.Path)
if containerName == "" {
return errors.New("podman export target missing container name")
}
cmd := fmt.Sprintf("podman --version >/dev/null && podman container exists %s", shellQuote(containerName))
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 {

View File

@ -101,6 +101,8 @@ func main() {
r.Post("/sites/{id}/run", a.handleSiteRun)
r.Post("/sites/{id}/sync", a.handleSiteSyncNow)
r.Post("/sites/{id}/mysql-dumps", a.handleSiteAddMySQLDump)
r.Post("/sites/{id}/podman-saves", a.handleSiteAddPodmanSave)
r.Post("/sites/{id}/podman-exports", a.handleSiteAddPodmanExport)
r.Post("/sites/{id}/cancel", a.handleSiteCancel)
r.Post("/sites/{id}/restart", a.handleSiteRestart)
r.Post("/jobs/{id}/cancel", a.handleJobCancel)
@ -462,6 +464,78 @@ func (a *app) handleSiteAddMySQLDump(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/?msg=mysql-added", http.StatusSeeOther)
}
func (a *app) handleSiteAddPodmanSave(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
}
imageName := strings.TrimSpace(r.FormValue("image_name"))
if imageName == "" {
http.Redirect(w, r, "/?msg=podman-invalid", http.StatusSeeOther)
return
}
if err := a.store.AddPodmanSaveTarget(r.Context(), siteID, imageName); err != nil {
http.Error(w, "failed to add podman save target", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/?msg=podman-added", http.StatusSeeOther)
}
func (a *app) handleSiteAddPodmanExport(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
}
containerName := strings.TrimSpace(r.FormValue("container_name"))
if containerName == "" {
http.Redirect(w, r, "/?msg=podman-export-invalid", http.StatusSeeOther)
return
}
if err := a.store.AddPodmanExportTarget(r.Context(), siteID, containerName); err != nil {
http.Error(w, "failed to add podman export target", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/?msg=podman-export-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)

View File

@ -226,6 +226,12 @@ func queryTargetSize(ctx context.Context, site store.Site, target store.SiteTarg
if target.Mode == "mysql_dump" {
return queryMySQLTargetStatus(ctx, site, target)
}
if target.Mode == "podman_save" {
return queryPodmanTargetStatus(ctx, site, target)
}
if target.Mode == "podman_export" {
return queryPodmanExportTargetStatus(ctx, site, target)
}
targetAddr := fmt.Sprintf("%s@%s", site.SSHUser, site.Host)
cmdCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
@ -268,6 +274,48 @@ func queryMySQLTargetStatus(ctx context.Context, site store.Site, target store.S
return 0, nil
}
func queryPodmanTargetStatus(ctx context.Context, site store.Site, target store.SiteTarget) (int64, error) {
imageName := strings.TrimSpace(target.Path)
if imageName == "" {
return 0, errors.New("podman target missing image name")
}
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, fmt.Sprintf("podman --version >/dev/null && podman image exists %s", shellQuote(imageName)))
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 queryPodmanExportTargetStatus(ctx context.Context, site store.Site, target store.SiteTarget) (int64, error) {
containerName := strings.TrimSpace(target.Path)
if containerName == "" {
return 0, errors.New("podman export target missing container name")
}
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, fmt.Sprintf("podman --version >/dev/null && podman container exists %s", shellQuote(containerName)))
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" {
@ -276,6 +324,12 @@ func remoteSizeCommand(target store.SiteTarget) string {
if target.Mode == "mysql_dump" {
return ""
}
if target.Mode == "podman_save" {
return ""
}
if target.Mode == "podman_export" {
return ""
}
return fmt.Sprintf("du -sb -- %s | awk '{print $1}'", path)
}

View File

@ -63,7 +63,7 @@ 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', 'mysql_dump')),
mode TEXT NOT NULL CHECK(mode IN ('directory', 'sqlite_dump', 'mysql_dump', 'podman_save', 'podman_export')),
mysql_host TEXT,
mysql_user TEXT,
mysql_db TEXT,
@ -213,7 +213,7 @@ 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', 'mysql_dump')),
mode TEXT NOT NULL CHECK(mode IN ('directory', 'sqlite_dump', 'mysql_dump', 'podman_save', 'podman_export')),
mysql_host TEXT,
mysql_user TEXT,
mysql_db TEXT,
@ -277,6 +277,96 @@ FROM site_targets`); err != nil {
return err
},
},
{
version: 7,
name: "site_targets_podman_save_mode",
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', 'podman_save', 'podman_export')),
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
}
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, mysql_user, mysql_db, mysql_password, 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: 8,
name: "site_targets_podman_export_mode",
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', 'podman_save', 'podman_export')),
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
}
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, mysql_user, mysql_db, mysql_password, 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
},
},
}
for _, m := range migrations {

View File

@ -379,6 +379,32 @@ func (s *Store) AddMySQLDumpTarget(ctx context.Context, siteID int64, dbHost, db
return err
}
func (s *Store) AddPodmanSaveTarget(ctx context.Context, siteID int64, imageName string) error {
_, err := s.db.ExecContext(
ctx,
`INSERT INTO site_targets (site_id, path, mode, mysql_host, mysql_user, mysql_db, mysql_password) VALUES (?, ?, 'podman_save', NULL, NULL, NULL, NULL)`,
siteID,
imageName,
)
if err == nil {
s.debugDB("podman save target added", zap.Int64("site_id", siteID), zap.String("image_name", imageName))
}
return err
}
func (s *Store) AddPodmanExportTarget(ctx context.Context, siteID int64, containerName string) error {
_, err := s.db.ExecContext(
ctx,
`INSERT INTO site_targets (site_id, path, mode, mysql_host, mysql_user, mysql_db, mysql_password) VALUES (?, ?, 'podman_export', NULL, NULL, NULL, NULL)`,
siteID,
containerName,
)
if err == nil {
s.debugDB("podman export target added", zap.Int64("site_id", siteID), zap.String("container_name", containerName))
}
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

View File

@ -117,7 +117,7 @@ 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 {
} else if (t.Mode == "mysql_dump" || t.Mode == "podman_save" || t.Mode == "podman_export") && t.LastScanAt.Valid {
sizeOrErr = "connection established"
} else if t.LastSizeByte.Valid {
sizeOrErr = formatBytes(t.LastSizeByte.Int64)
@ -129,6 +129,12 @@ func Dashboard(data DashboardData) templ.Component {
if t.MySQLHost.Valid && t.MySQLUser.Valid && t.MySQLDB.Valid {
pathText = fmt.Sprintf("db=%s user=%s host=%s", t.MySQLDB.String, t.MySQLUser.String, t.MySQLHost.String)
}
} else if t.Mode == "podman_save" {
label = "podman save"
pathText = "image=" + t.Path
} else if t.Mode == "podman_export" {
label = "podman export"
pathText = "container=" + t.Path
}
targets.WriteString(fmt.Sprintf(`<li><span class="pill %s">%s</span> <code>%s</code><span class="muted inline">%s</span><form class="inline-form" method="post" action="/sites/%d/targets/%d/delete"><button class="button ghost button-sm" type="submit">Remove</button></form></li>`,
targetModeClass(t.Mode),
@ -183,6 +189,20 @@ func Dashboard(data DashboardData) templ.Component {
<button class="button" type="submit">Add MySQL dump</button>
</form>
</details>
<details class="edit-panel">
<summary>Add Podman save operation</summary>
<form class="grid-2" method="post" action="/sites/%d/podman-saves">
<label class="stack"><span>Image Name</span><input name="image_name" placeholder="myimage:latest" required /></label>
<button class="button" type="submit">Add Podman save</button>
</form>
</details>
<details class="edit-panel">
<summary>Add Podman export operation</summary>
<form class="grid-2" method="post" action="/sites/%d/podman-exports">
<label class="stack"><span>Container Name</span><input name="container_name" placeholder="forgejo" required /></label>
<button class="button" type="submit">Add Podman export</button>
</form>
</details>
<p class="muted">Backup targets:</p>
<ul class="target-list">%s</ul>
<p class="muted">Filters: %s</p>
@ -213,6 +233,8 @@ func Dashboard(data DashboardData) templ.Component {
html.EscapeString(joinTargetPaths(site.Targets, "sqlite_dump")),
html.EscapeString(strings.Join(site.Filters, "\n")),
site.ID,
site.ID,
site.ID,
targets.String(),
html.EscapeString(formatFilters(site.Filters)),
html.EscapeString(scanState),
@ -590,6 +612,14 @@ func formatFlash(code string) string {
return "MySQL dump operation added."
case "mysql-invalid":
return "MySQL host, user, database, and password are required."
case "podman-added":
return "Podman save operation added."
case "podman-invalid":
return "Podman image name is required."
case "podman-export-added":
return "Podman export operation added."
case "podman-export-invalid":
return "Podman container name is required."
case "target-deleted":
return "Target removed."
case "target-not-found":
@ -610,6 +640,12 @@ func targetModeLabel(mode string) string {
if mode == "mysql_dump" {
return "mysql dump"
}
if mode == "podman_save" {
return "podman save"
}
if mode == "podman_export" {
return "podman export"
}
return "directory"
}
@ -631,7 +667,7 @@ func formatFilters(filters []string) string {
}
func targetModeClass(mode string) string {
if mode == "sqlite_dump" || mode == "mysql_dump" {
if mode == "sqlite_dump" || mode == "mysql_dump" || mode == "podman_save" || mode == "podman_export" {
return "sqlite"
}
return "ok"