From e0311f9d8f9a6cc7ba1971f428ee72556a66f242 Mon Sep 17 00:00:00 2001 From: Peter Li Date: Sat, 7 Feb 2026 22:08:32 -0800 Subject: [PATCH] restic backups --- AGENTS.md | 20 +++++ cmd/satoru/backup_job.go | 29 +++++-- cmd/satoru/config.go | 149 ++++++++++++++++++++++++++++++++++-- cmd/satoru/jobs.go | 12 +++ cmd/satoru/main.go | 87 ++++++++++++++++++--- cmd/satoru/restic_status.go | 111 +++++++++++++++++++++++++++ cmd/satoru/scanner.go | 81 +++++++++++++++++++- internal/webui/dashboard.go | 60 ++++++++++++--- 8 files changed, 514 insertions(+), 35 deletions(-) create mode 100644 cmd/satoru/restic_status.go diff --git a/AGENTS.md b/AGENTS.md index b76599d..497ebd1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,26 @@ LOG_FORMAT=json LOG_LEVEL=debug LOG_FILE=./logs/satoru.log go run ./cmd/satoru tail -f ./logs/satoru.log | jq ``` +## Config File (Optional) +You can configure runtime variables in a key-value file: +- default path: `/Users/peterli/git/satoru/data/satoru.conf` +- override path: `SATORU_CONFIG_FILE=/absolute/path/to/file` +- on first run, Satoru creates an example config automatically if missing +- if no explicit restic password is configured, Satoru auto-generates `RESTIC_PASSWORD` and appends it on first boot + +Format: +```bash +# comments allowed +RESTIC_PASSWORD=example-local-password +RESTIC_PASSWORD2=example-b2-password +B2_APPLICATION_ID=xxxx +B2_APPLICATION_KEY=yyyy +SATORU_RESTIC_B2_REPOSITORY=b2:bucket-name:repo-prefix +``` + +Precedence: +- environment variable overrides config file value. + ## Debug Logging Expectation Be proactive with debug-level logging for: 1. DB state changes (job/site/session mutations). diff --git a/cmd/satoru/backup_job.go b/cmd/satoru/backup_job.go index 3999e2e..6417b01 100644 --- a/cmd/satoru/backup_job.go +++ b/cmd/satoru/backup_job.go @@ -323,7 +323,7 @@ func (a *app) runRetentionJob(ctx context.Context, job store.Job, site store.Sit } func (a *app) runResticSyncJob(ctx context.Context, job store.Job, site store.Site) (string, string) { - b2Repo := strings.TrimSpace(os.Getenv("SATORU_RESTIC_B2_REPOSITORY")) + b2Repo := configValue("SATORU_RESTIC_B2_REPOSITORY") if b2Repo == "" { _ = a.store.AddJobEvent(ctx, store.JobEvent{JobID: job.ID, Level: "warn", Message: "restic sync skipped: SATORU_RESTIC_B2_REPOSITORY not set"}) return "warning", "restic sync skipped: B2 repository not configured" @@ -385,13 +385,25 @@ func ensureResticRepo(ctx context.Context, repoPath string) error { func resticEnv() []string { env := os.Environ() - password := strings.TrimSpace(os.Getenv("RESTIC_PASSWORD")) - passwordFile := strings.TrimSpace(os.Getenv("RESTIC_PASSWORD_FILE")) - password2 := strings.TrimSpace(os.Getenv("RESTIC_PASSWORD2")) - passwordFile2 := strings.TrimSpace(os.Getenv("RESTIC_PASSWORD_FILE2")) + 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 == "" { + // Use repo2-specific password when provided; otherwise fall back to primary/default password. + if password != "" { + password2 = password + } else { + password2 = configuredResticPassword() + } + } if password != "" { env = append(env, "RESTIC_PASSWORD="+password) } @@ -404,6 +416,13 @@ func resticEnv() []string { if passwordFile2 != "" { env = append(env, "RESTIC_PASSWORD_FILE2="+passwordFile2) } + // Prefer clearer B2 application naming and map to restic backend vars. + if b2AccountID == "" && b2ApplicationID != "" { + env = append(env, "B2_ACCOUNT_ID="+b2ApplicationID) + } + if b2AccountKey == "" && b2ApplicationKey != "" { + env = append(env, "B2_ACCOUNT_KEY="+b2ApplicationKey) + } return env } diff --git a/cmd/satoru/config.go b/cmd/satoru/config.go index b7821e0..af78cae 100644 --- a/cmd/satoru/config.go +++ b/cmd/satoru/config.go @@ -1,15 +1,46 @@ package main import ( + "bufio" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" "os" + "path/filepath" "strconv" "strings" + "sync" ) -const defaultResticPassword = "satoru-change-me" +var ( + confOnce sync.Once + confData map[string]string +) + +const defaultConfigBody = `# Satoru configuration file +# Environment variables override these values. +# +# Restic local repository password: +# RESTIC_PASSWORD=set-a-strong-value +# +# Restic B2 repository password (for repo2): +# RESTIC_PASSWORD2=set-a-strong-second-value +# +# Backblaze B2 credentials (application key format): +# B2_APPLICATION_ID=your-application-key-id +# B2_APPLICATION_KEY=your-application-key +# +# B2 restic repository target: +# SATORU_RESTIC_B2_REPOSITORY=b2:your-bucket:satoru-repo +# +# Optional local paths: +# SATORU_RESTIC_REPO=./repos/restic +# SATORU_STAGING_ROOT=./backups +` func getenvDefault(k, d string) string { - v := strings.TrimSpace(os.Getenv(k)) + v := configValue(k) if v == "" { return d } @@ -17,7 +48,7 @@ func getenvDefault(k, d string) string { } func parsePositiveIntEnv(name string, fallback int) int { - raw := strings.TrimSpace(os.Getenv(name)) + raw := configValue(name) if raw == "" { return fallback } @@ -29,11 +60,113 @@ func parsePositiveIntEnv(name string, fallback int) int { } func configuredResticPassword() string { - return getenvDefault("SATORU_RESTIC_PASSWORD", defaultResticPassword) + if v := configValue("RESTIC_PASSWORD"); v != "" { + return v + } + return configValue("SATORU_RESTIC_PASSWORD") } -func isUsingDefaultResticPassword() bool { - return strings.TrimSpace(os.Getenv("RESTIC_PASSWORD")) == "" && - strings.TrimSpace(os.Getenv("RESTIC_PASSWORD_FILE")) == "" && - strings.TrimSpace(os.Getenv("SATORU_RESTIC_PASSWORD")) == "" +func isMissingExplicitResticPassword() bool { + return configValue("RESTIC_PASSWORD") == "" && + configValue("RESTIC_PASSWORD_FILE") == "" && + configValue("SATORU_RESTIC_PASSWORD") == "" +} + +func configValue(key string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + confOnce.Do(loadConf) + if confData == nil { + return "" + } + return strings.TrimSpace(confData[key]) +} + +func loadConf() { + confData = map[string]string{} + confPath := resolveConfigPath() + + f, err := os.Open(confPath) + if err != nil { + return + } + defer f.Close() + + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + k, v, ok := strings.Cut(line, "=") + if !ok { + continue + } + k = strings.TrimSpace(k) + v = strings.TrimSpace(v) + v = strings.Trim(v, `"`) + if k == "" { + continue + } + confData[k] = v + } +} + +func resolveConfigPath() string { + confPath := strings.TrimSpace(os.Getenv("SATORU_CONFIG_FILE")) + if confPath != "" { + return confPath + } + return filepath.Join("data", "satoru.conf") +} + +func ensureExampleConfigFile() (string, bool, error) { + confPath := resolveConfigPath() + if _, err := os.Stat(confPath); err == nil { + return confPath, false, nil + } else if !errors.Is(err, os.ErrNotExist) { + return confPath, false, err + } + + if err := os.MkdirAll(filepath.Dir(confPath), 0o755); err != nil { + return confPath, false, err + } + if err := os.WriteFile(confPath, []byte(defaultConfigBody), 0o644); err != nil { + return confPath, false, err + } + return confPath, true, nil +} + +func ensureGeneratedResticPasswordInConfig() (bool, error) { + if !isMissingExplicitResticPassword() { + return false, nil + } + + confPath := resolveConfigPath() + password, err := generateRandomSecret(32) + if err != nil { + return false, err + } + f, err := os.OpenFile(confPath, os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return false, err + } + defer f.Close() + + if _, err := fmt.Fprintf(f, "\n# Auto-generated on first startup\nRESTIC_PASSWORD=%s\n", password); err != nil { + return false, err + } + if confData != nil { + confData["RESTIC_PASSWORD"] = password + } + return true, nil +} + +func generateRandomSecret(numBytes int) (string, error) { + buf := make([]byte, numBytes) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil } diff --git a/cmd/satoru/jobs.go b/cmd/satoru/jobs.go index 5cad46a..398b042 100644 --- a/cmd/satoru/jobs.go +++ b/cmd/satoru/jobs.go @@ -107,6 +107,7 @@ func (a *app) executeJob(ctx context.Context, job store.Job) { summary = "job canceled by user" } _ = a.store.CompleteJob(jobCtx, job.ID, status, summary) + _ = a.store.UpdateSiteRunResult(jobCtx, site.ID, status, summary, time.Now()) a.log.Info("job completed", zap.Int64("job_id", job.ID), zap.Int64("site_id", site.ID), zap.String("job_type", job.Type), zap.String("status", status), zap.String("summary", summary)) case jobTypeResticSync: status, summary := a.runResticSyncJob(jobCtx, job, site) @@ -115,6 +116,7 @@ func (a *app) executeJob(ctx context.Context, job store.Job) { summary = "job canceled by user" } _ = a.store.CompleteJob(jobCtx, job.ID, status, summary) + _ = a.store.UpdateSiteRunResult(jobCtx, site.ID, status, summary, time.Now()) a.log.Info("job completed", zap.Int64("job_id", job.ID), zap.Int64("site_id", site.ID), zap.String("job_type", job.Type), zap.String("status", status), zap.String("summary", summary)) default: summary := "unknown job type" @@ -273,6 +275,16 @@ func (a *app) enqueueBackupJob(ctx context.Context, siteID int64) (store.Job, er return job, nil } +func (a *app) enqueueResticSyncJob(ctx context.Context, siteID int64) (store.Job, error) { + job, err := a.store.CreateJob(ctx, siteID, jobTypeResticSync) + if err != nil { + return store.Job{}, err + } + _ = a.store.UpdateSiteRunResult(ctx, siteID, "queued", fmt.Sprintf("Job #%d queued (restic_sync)", job.ID), time.Now()) + _ = a.store.AddJobEvent(ctx, store.JobEvent{JobID: job.ID, Level: "info", Message: "Restic sync job queued"}) + return job, nil +} + func (a *app) latestJobForSite(ctx context.Context, siteID int64) (store.Job, error) { jobs, err := a.store.ListRecentJobs(ctx, 200) if err != nil { diff --git a/cmd/satoru/main.go b/cmd/satoru/main.go index b7bcc9e..f424c75 100644 --- a/cmd/satoru/main.go +++ b/cmd/satoru/main.go @@ -34,6 +34,9 @@ type app struct { jobClaimMu sync.Mutex jobCancelMu sync.Mutex jobCancels map[int64]context.CancelFunc + b2ProbeNote string + b2InitRepo string + b2InitReady bool } func main() { @@ -46,6 +49,23 @@ func main() { if err := os.MkdirAll("data", 0o755); err != nil { logger.Fatal("failed to create data directory", zap.Error(err)) } + confPath, created, err := ensureExampleConfigFile() + if err != nil { + logger.Fatal("failed to ensure config file", zap.Error(err)) + } + if created { + logger.Info("created example config file", zap.String("path", confPath)) + } + generated, err := ensureGeneratedResticPasswordInConfig() + if err != nil { + logger.Fatal("failed to ensure restic password", zap.Error(err)) + } + if generated { + logger.Info("generated restic password in config", zap.String("path", confPath)) + } + if strings.TrimSpace(os.Getenv("SATORU_CONFIG_FILE")) != "" { + logger.Info("using explicit config file", zap.String("path", os.Getenv("SATORU_CONFIG_FILE"))) + } dbPath := filepath.Join("data", "satoru.db") st, err := store.Open(dbPath) if err != nil { @@ -59,9 +79,7 @@ func main() { log: logger, jobCancels: map[int64]context.CancelFunc{}, } - if isUsingDefaultResticPassword() { - logger.Warn("using built-in default restic password; set SATORU_RESTIC_PASSWORD or RESTIC_PASSWORD for production") - } + a.runStartupB2Probe(context.Background()) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -76,10 +94,12 @@ func main() { r.Get("/", a.handleHome) r.Get("/ws/progress", a.handleProgressWebSocket) + r.Post("/b2/init", a.handleB2Init) r.Get("/account/password", a.handlePasswordPage) r.Post("/account/password", a.handlePasswordSubmit) r.Post("/sites", a.handleSiteCreate) 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}/cancel", a.handleSiteCancel) r.Post("/sites/{id}/restart", a.handleSiteRestart) @@ -114,17 +134,37 @@ func (a *app) handleHome(w http.ResponseWriter, r *http.Request) { } data := webui.DashboardData{ - Now: time.Now(), - ScanInterval: scanInterval, - User: user, - Sites: sites, - RuntimeChecks: runtimeChecks(), - FlashMessage: r.URL.Query().Get("msg"), - WorkflowStages: defaultWorkflowStages(), + Now: time.Now(), + ScanInterval: scanInterval, + User: user, + Sites: sites, + RuntimeChecks: a.runtimeChecks(), + FlashMessage: r.URL.Query().Get("msg"), + WorkflowStages: defaultWorkflowStages(), + ShowB2InitButton: a.b2InitReady, + B2Repo: a.b2InitRepo, } + resticCtx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + data.ResticRepoPath, data.ResticRepoSize, data.ResticSnapshots = a.localResticOverview(resticCtx) + cancel() templ.Handler(webui.Dashboard(data)).ServeHTTP(w, r) } +func (a *app) handleB2Init(w http.ResponseWriter, r *http.Request) { + if _, err := a.currentUserWithRollingSession(w, r); err != nil { + http.Redirect(w, r, "/signin", http.StatusSeeOther) + return + } + if err := a.initB2Repo(r.Context()); err != nil { + a.log.Warn("b2 init failed", zap.Error(err)) + a.runStartupB2Probe(context.Background()) + http.Redirect(w, r, "/?msg=b2-init-failed", http.StatusSeeOther) + return + } + a.runStartupB2Probe(context.Background()) + http.Redirect(w, r, "/?msg=b2-init-ok", http.StatusSeeOther) +} + func (a *app) handleSignupPage(w http.ResponseWriter, r *http.Request) { if _, err := a.currentUserWithRollingSession(w, r); err == nil { http.Redirect(w, r, "/", http.StatusSeeOther) @@ -355,6 +395,33 @@ func (a *app) handleSiteRun(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/?msg=job-queued", http.StatusSeeOther) } +func (a *app) handleSiteSyncNow(w http.ResponseWriter, r *http.Request) { + if _, err := a.currentUserWithRollingSession(w, r); err != nil { + http.Redirect(w, r, "/signin", http.StatusSeeOther) + return + } + + id, 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(), id); 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 := a.enqueueResticSyncJob(r.Context(), id); err != nil { + http.Error(w, "failed to queue sync", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/?msg=sync-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) diff --git a/cmd/satoru/restic_status.go b/cmd/satoru/restic_status.go new file mode 100644 index 0000000..8ee3342 --- /dev/null +++ b/cmd/satoru/restic_status.go @@ -0,0 +1,111 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" +) + +type resticSnapshotRow struct { + ID string `json:"id"` + Time time.Time `json:"time"` + Tags []string `json:"tags"` +} + +func (a *app) localResticOverview(ctx context.Context) (string, string, []string) { + repoPath := getenvDefault("SATORU_RESTIC_REPO", defaultResticRepo) + sizeText := "not initialized" + + if size, err := dirSizeBytes(repoPath); err == nil { + sizeText = humanBytes(size) + } else if !os.IsNotExist(err) { + sizeText = "error: " + strings.TrimSpace(err.Error()) + } + + cmdCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + cmd := exec.CommandContext(cmdCtx, "restic", "-r", repoPath, "snapshots", "--json") + cmd.Env = resticEnv() + out, err := cmd.CombinedOutput() + if err != nil { + msg := strings.TrimSpace(string(out)) + if msg == "" { + msg = err.Error() + } + return repoPath, sizeText, []string{"error: " + clipDetail(msg, 180)} + } + + var rows []resticSnapshotRow + if err := json.Unmarshal(out, &rows); err != nil { + return repoPath, sizeText, []string{"error: invalid snapshots json"} + } + if len(rows) == 0 { + return repoPath, sizeText, []string{"(no snapshots yet)"} + } + + sort.Slice(rows, func(i, j int) bool { return rows[i].Time.After(rows[j].Time) }) + limit := len(rows) + if limit > 25 { + limit = 25 + } + lines := make([]string, 0, limit) + for i := 0; i < limit; i++ { + r := rows[i] + tags := "(no tags)" + if len(r.Tags) > 0 { + tags = strings.Join(r.Tags, ",") + } + lines = append(lines, fmt.Sprintf("%s %s %s", shortID(r.ID), r.Time.Local().Format(time.RFC3339), tags)) + } + return repoPath, sizeText, lines +} + +func shortID(v string) string { + if len(v) <= 8 { + return v + } + return v[:8] +} + +func dirSizeBytes(root string) (int64, error) { + var total int64 + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + info, err := d.Info() + if err != nil { + return err + } + total += info.Size() + return nil + }) + if err != nil { + return 0, err + } + return total, nil +} + +func humanBytes(v int64) string { + units := []string{"B", "KB", "MB", "GB", "TB"} + fv := float64(v) + u := 0 + for fv >= 1024 && u < len(units)-1 { + fv /= 1024 + u++ + } + if u == 0 { + return fmt.Sprintf("%d %s", v, units[u]) + } + return fmt.Sprintf("%.2f %s", fv, units[u]) +} diff --git a/cmd/satoru/scanner.go b/cmd/satoru/scanner.go index ba5fec1..457399d 100644 --- a/cmd/satoru/scanner.go +++ b/cmd/satoru/scanner.go @@ -16,8 +16,8 @@ import ( "go.uber.org/zap" ) -func runtimeChecks() []webui.RuntimeCheck { - tools := []string{"restic", "rsync", "ssh"} +func (a *app) runtimeChecks() []webui.RuntimeCheck { + tools := []string{"restic", "rsync", "ssh", "b2"} out := make([]webui.RuntimeCheck, 0, len(tools)) for _, name := range tools { path, err := exec.LookPath(name) @@ -25,11 +25,86 @@ func runtimeChecks() []webui.RuntimeCheck { out = append(out, webui.RuntimeCheck{Name: name, Installed: false, Details: "not found in PATH"}) continue } - out = append(out, webui.RuntimeCheck{Name: name, Installed: true, Details: path}) + details := path + if name == "b2" && a.b2ProbeNote != "" { + details = details + " | " + a.b2ProbeNote + } + out = append(out, webui.RuntimeCheck{Name: name, Installed: true, Details: details}) } return out } +func (a *app) runStartupB2Probe(ctx context.Context) { + if _, err := exec.LookPath("b2"); err != nil { + a.b2ProbeNote = "startup probe skipped: b2 cli not installed" + a.b2InitReady = false + a.b2InitRepo = "" + return + } + + repo := configValue("SATORU_RESTIC_B2_REPOSITORY") + a.b2InitRepo = repo + if repo == "" { + a.b2ProbeNote = "startup probe skipped: SATORU_RESTIC_B2_REPOSITORY not set" + a.b2InitReady = false + return + } + hasID := configValue("B2_ACCOUNT_ID") != "" || configValue("B2_APPLICATION_ID") != "" + hasKey := configValue("B2_ACCOUNT_KEY") != "" || configValue("B2_APPLICATION_KEY") != "" + if !hasID || !hasKey { + a.b2ProbeNote = "startup probe skipped: B2 credentials not fully set" + a.b2InitReady = false + return + } + + cmdCtx, cancel := context.WithTimeout(ctx, 25*time.Second) + defer cancel() + + cmd := exec.CommandContext(cmdCtx, "restic", "-r", repo, "cat", "config") + cmd.Env = resticEnv() + out, err := cmd.CombinedOutput() + if err != nil { + msg := strings.TrimSpace(string(out)) + if msg == "" { + msg = err.Error() + } + a.b2InitReady = strings.Contains(msg, "repository does not exist") + a.b2ProbeNote = "startup probe failed: " + clipDetail(msg, 140) + a.log.Warn("b2 startup probe failed", zap.String("repo", repo), zap.String("detail", msg)) + return + } + a.b2InitReady = false + a.b2ProbeNote = "startup probe ok: b2 reachable and repo exists" + a.log.Info("b2 startup probe ok", zap.String("repo", repo)) +} + +func (a *app) initB2Repo(ctx context.Context) error { + repo := configValue("SATORU_RESTIC_B2_REPOSITORY") + if repo == "" { + return errors.New("SATORU_RESTIC_B2_REPOSITORY not set") + } + cmdCtx, cancel := context.WithTimeout(ctx, 40*time.Second) + defer cancel() + cmd := exec.CommandContext(cmdCtx, "restic", "-r", repo, "init") + cmd.Env = resticEnv() + out, err := cmd.CombinedOutput() + if err != nil { + msg := strings.TrimSpace(string(out)) + if msg == "" { + msg = err.Error() + } + return errors.New(msg) + } + return nil +} + +func clipDetail(v string, max int) string { + if len(v) <= max { + return v + } + return v[:max-3] + "..." +} + func runSSHHello(ctx context.Context, site store.Site) (string, string) { target := fmt.Sprintf("%s@%s", site.SSHUser, site.Host) cmdCtx, cancel := context.WithTimeout(ctx, 10*time.Second) diff --git a/internal/webui/dashboard.go b/internal/webui/dashboard.go index 6d9b10b..e4323d4 100644 --- a/internal/webui/dashboard.go +++ b/internal/webui/dashboard.go @@ -26,13 +26,18 @@ type WorkflowStage struct { } type DashboardData struct { - Now time.Time - ScanInterval time.Duration - User store.User - Sites []store.Site - RuntimeChecks []RuntimeCheck - WorkflowStages []WorkflowStage - FlashMessage string + Now time.Time + ScanInterval time.Duration + User store.User + Sites []store.Site + RuntimeChecks []RuntimeCheck + WorkflowStages []WorkflowStage + FlashMessage string + ShowB2InitButton bool + B2Repo string + ResticRepoPath string + ResticRepoSize string + ResticSnapshots []string } func Dashboard(data DashboardData) templ.Component { @@ -148,6 +153,9 @@ func Dashboard(data DashboardData) templ.Component {
+
+ +
@@ -197,6 +205,7 @@ func Dashboard(data DashboardData) templ.Component { site.ID, site.ID, site.ID, + site.ID, html.EscapeString(site.SSHUser), html.EscapeString(site.Host), site.Port, @@ -228,6 +237,17 @@ func Dashboard(data DashboardData) templ.Component { if data.FlashMessage != "" { flash = fmt.Sprintf(`

%s

`, html.EscapeString(formatFlash(data.FlashMessage))) } + b2Init := "" + if data.ShowB2InitButton { + b2Init = fmt.Sprintf(`

Repository: %s

`, html.EscapeString(data.B2Repo)) + } + var resticSnaps strings.Builder + for _, s := range data.ResticSnapshots { + resticSnaps.WriteString(fmt.Sprintf(`
  • %s
  • `, html.EscapeString(s))) + } + if len(data.ResticSnapshots) == 0 { + resticSnaps.WriteString(`
  • (no snapshots yet)
  • `) + } _, err := io.WriteString(w, fmt.Sprintf(` @@ -258,6 +278,7 @@ func Dashboard(data DashboardData) templ.Component {

    Runtime Checks

    + %s
    @@ -286,6 +307,13 @@ func Dashboard(data DashboardData) templ.Component {
    No live events yet.
    +
    +

    Local Restic Repository

    +

    Path: %s

    +

    Size: %s

    +

    Snapshots:

    +
      %s
    +

    Planned Workflow

      %s
    @@ -446,11 +474,15 @@ func Dashboard(data DashboardData) templ.Component { if (siteButtons.length === 0) { return; } + const stored = Number(window.localStorage.getItem("satoru.active_site_id") || ""); + const hasStored = siteButtons.some((btn) => Number(btn.getAttribute("data-site-select")) === stored); const firstID = Number(siteButtons[0].getAttribute("data-site-select")); - setActiveSite(firstID); + setActiveSite(hasStored ? stored : firstID); siteButtons.forEach((btn) => { btn.addEventListener("click", function () { - setActiveSite(Number(btn.getAttribute("data-site-select"))); + const id = Number(btn.getAttribute("data-site-select")); + setActiveSite(id); + window.localStorage.setItem("satoru.active_site_id", String(id)); }); }); } @@ -517,7 +549,11 @@ func Dashboard(data DashboardData) templ.Component { flash, siteNav.String(), checks.String(), + b2Init, sites.String(), + html.EscapeString(data.ResticRepoPath), + html.EscapeString(data.ResticRepoSize), + resticSnaps.String(), flows.String(), )) return err @@ -530,6 +566,8 @@ func formatFlash(code string) string { return "Site added." case "job-queued": return "Backup job queued." + case "sync-queued": + return "Restic sync job queued." case "job-cancel-requested": return "Cancel requested for active job." case "job-restarted": @@ -556,6 +594,10 @@ func formatFlash(code string) string { return "Target removed." case "target-not-found": return "Target not found." + case "b2-init-ok": + return "B2 restic repository initialized." + case "b2-init-failed": + return "Failed to initialize B2 restic repository." default: return code }