restic backups

This commit is contained in:
Peter Li 2026-02-07 22:08:32 -08:00
parent a8d3bc66ad
commit e0311f9d8f
8 changed files with 514 additions and 35 deletions

View File

@ -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 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 ## Debug Logging Expectation
Be proactive with debug-level logging for: Be proactive with debug-level logging for:
1. DB state changes (job/site/session mutations). 1. DB state changes (job/site/session mutations).

View File

@ -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) { 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 == "" { if b2Repo == "" {
_ = a.store.AddJobEvent(ctx, store.JobEvent{JobID: job.ID, Level: "warn", Message: "restic sync skipped: SATORU_RESTIC_B2_REPOSITORY not set"}) _ = 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" return "warning", "restic sync skipped: B2 repository not configured"
@ -385,13 +385,25 @@ func ensureResticRepo(ctx context.Context, repoPath string) error {
func resticEnv() []string { func resticEnv() []string {
env := os.Environ() env := os.Environ()
password := strings.TrimSpace(os.Getenv("RESTIC_PASSWORD")) password := configValue("RESTIC_PASSWORD")
passwordFile := strings.TrimSpace(os.Getenv("RESTIC_PASSWORD_FILE")) passwordFile := configValue("RESTIC_PASSWORD_FILE")
password2 := strings.TrimSpace(os.Getenv("RESTIC_PASSWORD2")) password2 := configValue("RESTIC_PASSWORD2")
passwordFile2 := strings.TrimSpace(os.Getenv("RESTIC_PASSWORD_FILE2")) 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 == "" { if password == "" && passwordFile == "" {
password = configuredResticPassword() 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 != "" { if password != "" {
env = append(env, "RESTIC_PASSWORD="+password) env = append(env, "RESTIC_PASSWORD="+password)
} }
@ -404,6 +416,13 @@ func resticEnv() []string {
if passwordFile2 != "" { if passwordFile2 != "" {
env = append(env, "RESTIC_PASSWORD_FILE2="+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 return env
} }

View File

@ -1,15 +1,46 @@
package main package main
import ( import (
"bufio"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"os" "os"
"path/filepath"
"strconv" "strconv"
"strings" "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 { func getenvDefault(k, d string) string {
v := strings.TrimSpace(os.Getenv(k)) v := configValue(k)
if v == "" { if v == "" {
return d return d
} }
@ -17,7 +48,7 @@ func getenvDefault(k, d string) string {
} }
func parsePositiveIntEnv(name string, fallback int) int { func parsePositiveIntEnv(name string, fallback int) int {
raw := strings.TrimSpace(os.Getenv(name)) raw := configValue(name)
if raw == "" { if raw == "" {
return fallback return fallback
} }
@ -29,11 +60,113 @@ func parsePositiveIntEnv(name string, fallback int) int {
} }
func configuredResticPassword() string { 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 { func isMissingExplicitResticPassword() bool {
return strings.TrimSpace(os.Getenv("RESTIC_PASSWORD")) == "" && return configValue("RESTIC_PASSWORD") == "" &&
strings.TrimSpace(os.Getenv("RESTIC_PASSWORD_FILE")) == "" && configValue("RESTIC_PASSWORD_FILE") == "" &&
strings.TrimSpace(os.Getenv("SATORU_RESTIC_PASSWORD")) == "" 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
} }

View File

@ -107,6 +107,7 @@ func (a *app) executeJob(ctx context.Context, job store.Job) {
summary = "job canceled by user" summary = "job canceled by user"
} }
_ = a.store.CompleteJob(jobCtx, job.ID, status, summary) _ = 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)) 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: case jobTypeResticSync:
status, summary := a.runResticSyncJob(jobCtx, job, site) 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" summary = "job canceled by user"
} }
_ = a.store.CompleteJob(jobCtx, job.ID, status, summary) _ = 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)) 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: default:
summary := "unknown job type" summary := "unknown job type"
@ -273,6 +275,16 @@ func (a *app) enqueueBackupJob(ctx context.Context, siteID int64) (store.Job, er
return job, nil 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) { func (a *app) latestJobForSite(ctx context.Context, siteID int64) (store.Job, error) {
jobs, err := a.store.ListRecentJobs(ctx, 200) jobs, err := a.store.ListRecentJobs(ctx, 200)
if err != nil { if err != nil {

View File

@ -34,6 +34,9 @@ type app struct {
jobClaimMu sync.Mutex jobClaimMu sync.Mutex
jobCancelMu sync.Mutex jobCancelMu sync.Mutex
jobCancels map[int64]context.CancelFunc jobCancels map[int64]context.CancelFunc
b2ProbeNote string
b2InitRepo string
b2InitReady bool
} }
func main() { func main() {
@ -46,6 +49,23 @@ func main() {
if err := os.MkdirAll("data", 0o755); err != nil { if err := os.MkdirAll("data", 0o755); err != nil {
logger.Fatal("failed to create data directory", zap.Error(err)) 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") dbPath := filepath.Join("data", "satoru.db")
st, err := store.Open(dbPath) st, err := store.Open(dbPath)
if err != nil { if err != nil {
@ -59,9 +79,7 @@ func main() {
log: logger, log: logger,
jobCancels: map[int64]context.CancelFunc{}, jobCancels: map[int64]context.CancelFunc{},
} }
if isUsingDefaultResticPassword() { a.runStartupB2Probe(context.Background())
logger.Warn("using built-in default restic password; set SATORU_RESTIC_PASSWORD or RESTIC_PASSWORD for production")
}
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
@ -76,10 +94,12 @@ func main() {
r.Get("/", a.handleHome) r.Get("/", a.handleHome)
r.Get("/ws/progress", a.handleProgressWebSocket) r.Get("/ws/progress", a.handleProgressWebSocket)
r.Post("/b2/init", a.handleB2Init)
r.Get("/account/password", a.handlePasswordPage) r.Get("/account/password", a.handlePasswordPage)
r.Post("/account/password", a.handlePasswordSubmit) r.Post("/account/password", a.handlePasswordSubmit)
r.Post("/sites", a.handleSiteCreate) r.Post("/sites", a.handleSiteCreate)
r.Post("/sites/{id}/run", a.handleSiteRun) 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}/mysql-dumps", a.handleSiteAddMySQLDump)
r.Post("/sites/{id}/cancel", a.handleSiteCancel) r.Post("/sites/{id}/cancel", a.handleSiteCancel)
r.Post("/sites/{id}/restart", a.handleSiteRestart) r.Post("/sites/{id}/restart", a.handleSiteRestart)
@ -114,17 +134,37 @@ func (a *app) handleHome(w http.ResponseWriter, r *http.Request) {
} }
data := webui.DashboardData{ data := webui.DashboardData{
Now: time.Now(), Now: time.Now(),
ScanInterval: scanInterval, ScanInterval: scanInterval,
User: user, User: user,
Sites: sites, Sites: sites,
RuntimeChecks: runtimeChecks(), RuntimeChecks: a.runtimeChecks(),
FlashMessage: r.URL.Query().Get("msg"), FlashMessage: r.URL.Query().Get("msg"),
WorkflowStages: defaultWorkflowStages(), 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) 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) { func (a *app) handleSignupPage(w http.ResponseWriter, r *http.Request) {
if _, err := a.currentUserWithRollingSession(w, r); err == nil { if _, err := a.currentUserWithRollingSession(w, r); err == nil {
http.Redirect(w, r, "/", http.StatusSeeOther) 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) 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) { func (a *app) handleSiteAddMySQLDump(w http.ResponseWriter, r *http.Request) {
if _, err := a.currentUserWithRollingSession(w, r); err != nil { if _, err := a.currentUserWithRollingSession(w, r); err != nil {
http.Redirect(w, r, "/signin", http.StatusSeeOther) http.Redirect(w, r, "/signin", http.StatusSeeOther)

111
cmd/satoru/restic_status.go Normal file
View File

@ -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])
}

View File

@ -16,8 +16,8 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
) )
func runtimeChecks() []webui.RuntimeCheck { func (a *app) runtimeChecks() []webui.RuntimeCheck {
tools := []string{"restic", "rsync", "ssh"} tools := []string{"restic", "rsync", "ssh", "b2"}
out := make([]webui.RuntimeCheck, 0, len(tools)) out := make([]webui.RuntimeCheck, 0, len(tools))
for _, name := range tools { for _, name := range tools {
path, err := exec.LookPath(name) 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"}) out = append(out, webui.RuntimeCheck{Name: name, Installed: false, Details: "not found in PATH"})
continue 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 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) { func runSSHHello(ctx context.Context, site store.Site) (string, string) {
target := fmt.Sprintf("%s@%s", site.SSHUser, site.Host) target := fmt.Sprintf("%s@%s", site.SSHUser, site.Host)
cmdCtx, cancel := context.WithTimeout(ctx, 10*time.Second) cmdCtx, cancel := context.WithTimeout(ctx, 10*time.Second)

View File

@ -26,13 +26,18 @@ type WorkflowStage struct {
} }
type DashboardData struct { type DashboardData struct {
Now time.Time Now time.Time
ScanInterval time.Duration ScanInterval time.Duration
User store.User User store.User
Sites []store.Site Sites []store.Site
RuntimeChecks []RuntimeCheck RuntimeChecks []RuntimeCheck
WorkflowStages []WorkflowStage WorkflowStages []WorkflowStage
FlashMessage string FlashMessage string
ShowB2InitButton bool
B2Repo string
ResticRepoPath string
ResticRepoSize string
ResticSnapshots []string
} }
func Dashboard(data DashboardData) templ.Component { func Dashboard(data DashboardData) templ.Component {
@ -148,6 +153,9 @@ func Dashboard(data DashboardData) templ.Component {
<form method="post" action="/sites/%d/restart"> <form method="post" action="/sites/%d/restart">
<button class="button ghost" type="submit">Restart backup</button> <button class="button ghost" type="submit">Restart backup</button>
</form> </form>
<form method="post" action="/sites/%d/sync">
<button class="button ghost" type="submit">Sync to B2</button>
</form>
<form method="post" action="/sites/%d/delete" onsubmit="return confirm('Delete this site?');"> <form method="post" action="/sites/%d/delete" onsubmit="return confirm('Delete this site?');">
<button class="button ghost" type="submit">Delete</button> <button class="button ghost" type="submit">Delete</button>
</form> </form>
@ -197,6 +205,7 @@ func Dashboard(data DashboardData) templ.Component {
site.ID, site.ID,
site.ID, site.ID,
site.ID, site.ID,
site.ID,
html.EscapeString(site.SSHUser), html.EscapeString(site.SSHUser),
html.EscapeString(site.Host), html.EscapeString(site.Host),
site.Port, site.Port,
@ -228,6 +237,17 @@ func Dashboard(data DashboardData) templ.Component {
if data.FlashMessage != "" { if data.FlashMessage != "" {
flash = fmt.Sprintf(`<p class="notice">%s</p>`, html.EscapeString(formatFlash(data.FlashMessage))) flash = fmt.Sprintf(`<p class="notice">%s</p>`, html.EscapeString(formatFlash(data.FlashMessage)))
} }
b2Init := ""
if data.ShowB2InitButton {
b2Init = fmt.Sprintf(`<form method="post" action="/b2/init"><button class="button button-sm" type="submit">Initialize B2 Repo</button><p class="muted">Repository: <code>%s</code></p></form>`, html.EscapeString(data.B2Repo))
}
var resticSnaps strings.Builder
for _, s := range data.ResticSnapshots {
resticSnaps.WriteString(fmt.Sprintf(`<li><code>%s</code></li>`, html.EscapeString(s)))
}
if len(data.ResticSnapshots) == 0 {
resticSnaps.WriteString(`<li class="muted">(no snapshots yet)</li>`)
}
_, err := io.WriteString(w, fmt.Sprintf(`<!doctype html> _, err := io.WriteString(w, fmt.Sprintf(`<!doctype html>
<html lang="en"> <html lang="en">
@ -258,6 +278,7 @@ func Dashboard(data DashboardData) templ.Component {
<div class="site-nav-list">%s</div> <div class="site-nav-list">%s</div>
<h2>Runtime Checks</h2> <h2>Runtime Checks</h2>
<ul class="status-list">%s</ul> <ul class="status-list">%s</ul>
%s
</aside> </aside>
<section class="site-main-panel"> <section class="site-main-panel">
<section> <section>
@ -286,6 +307,13 @@ func Dashboard(data DashboardData) templ.Component {
</div> </div>
<pre class="output" id="live-events">No live events yet.</pre> <pre class="output" id="live-events">No live events yet.</pre>
</section> </section>
<section>
<h2>Local Restic Repository</h2>
<p class="muted">Path: <code>%s</code></p>
<p class="muted">Size: %s</p>
<p class="muted">Snapshots:</p>
<ul class="target-list">%s</ul>
</section>
<section> <section>
<h2>Planned Workflow</h2> <h2>Planned Workflow</h2>
<ol class="workflow">%s</ol> <ol class="workflow">%s</ol>
@ -446,11 +474,15 @@ func Dashboard(data DashboardData) templ.Component {
if (siteButtons.length === 0) { if (siteButtons.length === 0) {
return; 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")); const firstID = Number(siteButtons[0].getAttribute("data-site-select"));
setActiveSite(firstID); setActiveSite(hasStored ? stored : firstID);
siteButtons.forEach((btn) => { siteButtons.forEach((btn) => {
btn.addEventListener("click", function () { 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, flash,
siteNav.String(), siteNav.String(),
checks.String(), checks.String(),
b2Init,
sites.String(), sites.String(),
html.EscapeString(data.ResticRepoPath),
html.EscapeString(data.ResticRepoSize),
resticSnaps.String(),
flows.String(), flows.String(),
)) ))
return err return err
@ -530,6 +566,8 @@ func formatFlash(code string) string {
return "Site added." return "Site added."
case "job-queued": case "job-queued":
return "Backup job queued." return "Backup job queued."
case "sync-queued":
return "Restic sync job queued."
case "job-cancel-requested": case "job-cancel-requested":
return "Cancel requested for active job." return "Cancel requested for active job."
case "job-restarted": case "job-restarted":
@ -556,6 +594,10 @@ func formatFlash(code string) string {
return "Target removed." return "Target removed."
case "target-not-found": case "target-not-found":
return "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: default:
return code return code
} }