Tier 2 for All Apps + Status Dot Update (v0.12.9)

This commit is contained in:
2026-02-18 12:07:34 +01:00
parent 4a9aea647b
commit f9c0338894
3 changed files with 370 additions and 584 deletions
+311 -554
View File
@@ -1,4 +1,4 @@
# TASK.md — Complete Cross-Drive Backup + Per-Tier UI (v0.12.8)
# TASK.md — Tier 2 for All Apps + Status Dot Update (v0.12.9)
## Prompt (copy-paste this into Claude Code)
@@ -6,7 +6,7 @@
Read TASK.md for the full plan. Apply all code changes described, then build and deploy.
After all fixes are done:
1. Run `go build ./...` and `go vet ./...` from the controller/ directory — fix any errors
2. Update CHANGELOG.md with a new entry at the top (session 45, v0.12.8)
2. Update CHANGELOG.md with a new entry at the top (session 46, v0.12.9)
3. Commit, build, and deploy following the workflow in CLAUDE.md
```
@@ -14,525 +14,304 @@ After all fixes are done:
## Context and Goals
The cross-drive backup (Tier 2) currently only copies HDD user data (mounts). It does NOT
include DB dumps or app config. If the primary drive fails, the customer loses their database
and config — even though photos are safe on the second drive. **This is not a viable backup.**
Currently Tier 2 (cross-drive backup) is only available for apps with HDD data (Immich,
Paperless-ngx, etc.). Apps without HDD data (Mealie, Gokapi, etc.) cannot configure Tier 2
at all — the config section is hidden, the code rejects empty mounts, and the UI shows them
as "auto" (gray dot).
Each tier must be a **complete, self-sufficient backup**:
**Problem:** These apps have only 1 tier of protection. If the primary drive fails, their DB
and config are lost. The customer should be able to configure Tier 2 for ANY app.
| Tier | Contents | Location | Can fully restore? |
|------|----------|----------|--------------------|
| 1. Nightly restic | DB + Config + User data | Same drive | Yes (not against drive failure) |
| 2. Cross-drive | DB + Config + User data | Different drive | **Yes (after this fix)** |
| 3. Remote (future) | Everything | Offsite | Yes |
**Changes in this version:**
This task also restructures the UI from per-layer (DB/Config/UserData rows) to per-tier
(1st backup / 2nd backup rows) — matching the customer mental model.
1. **Tier 2 for all apps** — Remove all HDD-only gates. Non-HDD apps back up config + DB
dumps to the secondary drive (small, but protects against drive failure).
2. **Status dot update** — Remove "auto" (gray). All apps start as yellow (1 tier only).
Green requires 2+ tiers with successful backups.
3. **Tier 3 placeholder** — Show a disabled "3. mentés" row in the UI (future: remote backup).
4. **Deploy page** — Show cross-drive config form for ALL deployed apps, not just HDD ones.
**What non-HDD apps back up in Tier 2:**
- App with DB (e.g., Mealie): `_config/` + `_db/mealie_postgres.sql`
- App without DB (e.g., Gokapi): `_config/` only
- Small files — seconds to rsync/restic, but provides drive-failure protection.
---
## Fix 1: Include DB dumps + config in cross-drive backup
### 1a: Add `dbDumpDir` field to CrossDriveRunner
## Fix 1: Remove empty-mounts gate from RunAppBackup
**File:** `internal/backup/crossdrive.go`
Add a `dbDumpDir` field to the struct:
In `RunAppBackup()`, the code currently errors out when no HDD mounts exist (lines 98103):
```go
type CrossDriveRunner struct {
sett *settings.Settings
stackProvider StackDataProvider
dbDumper DBDumper
dbDumpDir string // path to DB dump directory (e.g., /srv/backups/db-dumps)
logger *log.Logger
mu sync.Mutex
running map[string]bool
}
```
Add setter after `SetDBDumper`:
```go
// SetDBDumpDir sets the path to the DB dump directory for cross-drive backups.
func (r *CrossDriveRunner) SetDBDumpDir(dir string) {
r.dbDumpDir = dir
}
```
### 1b: Add helper to copy DB dump files for a stack
**File:** `internal/backup/crossdrive.go`, add helper function:
```go
// copyStackDBDumps copies DB dump files for the given stack to destDir.
// DB dump files are named <stackName>_<dbtype>.sql (e.g., immich_postgres.sql).
// Small files — uses plain file copy, not rsync.
func (r *CrossDriveRunner) copyStackDBDumps(stackName, destDir string) error {
if r.dbDumpDir == "" {
return nil
}
entries, err := os.ReadDir(r.dbDumpDir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("reading DB dump dir: %w", err)
}
prefix := stackName + "_"
copied := 0
for _, e := range entries {
if e.IsDir() || !strings.HasPrefix(e.Name(), prefix) {
continue
}
src := filepath.Join(r.dbDumpDir, e.Name())
dst := filepath.Join(destDir, e.Name())
data, err := os.ReadFile(src)
if err != nil {
return fmt.Errorf("reading %s: %w", e.Name(), err)
}
if err := os.WriteFile(dst, data, 0644); err != nil {
return fmt.Errorf("writing %s: %w", e.Name(), err)
}
copied++
}
if copied > 0 {
r.logger.Printf("[DEBUG] Copied %d DB dump file(s) to %s", copied, destDir)
}
return nil
}
```
### 1c: Update `runRsyncBackup` to include DB + config
**File:** `internal/backup/crossdrive.go`, function `runRsyncBackup`
After the existing HDD mount rsync loop (after line 260 `return nil` of the for loop),
add these blocks BEFORE the final `return nil`:
```go
// --- Copy DB dumps for this stack ---
dbDestDir := filepath.Join(destDir, "_db")
if err := os.MkdirAll(dbDestDir, 0755); err != nil {
return fmt.Errorf("creating DB dump dest dir: %w", err)
}
if err := r.copyStackDBDumps(stackName, dbDestDir); err != nil {
r.logger.Printf("[WARN] Cross-drive DB dump copy failed for %s: %v", stackName, err)
// Non-fatal: user data is the primary concern
}
// --- Rsync app config (compose dir) ---
if composePath, ok := r.stackProvider.GetStackComposePath(stackName); ok {
configSrcDir := filepath.Dir(composePath)
configDestDir := filepath.Join(destDir, "_config")
if err := os.MkdirAll(configDestDir, 0755); err != nil {
return fmt.Errorf("creating config dest dir: %w", err)
}
src := strings.TrimRight(configSrcDir, "/") + "/"
dst := strings.TrimRight(configDestDir, "/") + "/"
cmd := exec.CommandContext(ctx, "rsync", "-a", "--delete", src, dst)
r.logger.Printf("[DEBUG] rsync config: %s → %s", src, dst)
if out, err := cmd.CombinedOutput(); err != nil {
r.logger.Printf("[WARN] Cross-drive config rsync failed for %s: %v (%s)", stackName, err, strings.TrimSpace(string(out)))
// Non-fatal
}
}
```
**Resulting rsync destination layout:**
```
backups/rsync/<app>/
_db/ ← stackName_postgres.sql, stackName_mariadb.sql
_config/ ← compose.yml, app.yaml, .felhom.yml
<user data> ← existing HDD mount contents (unchanged)
```
The `_` prefix prevents collision with user data directories (HDD mounts never start with `_`).
### 1d: Update `runResticBackup` to include DB + config
**File:** `internal/backup/crossdrive.go`, function `runResticBackup`
Currently the restic backup only includes `mounts`. Change to also include the config dir
and DB dump dir.
**Current code (around line 294301):**
```go
args := []string{
"backup", "--repo", repoPath,
"--password-file", pwPath,
"--tag", stackName,
"--tag", "cross-drive",
}
args = append(args, mounts...)
// CURRENT CODE — DELETE these 4 lines:
mounts := r.stackProvider.GetStackHDDMounts(stackName)
if len(mounts) == 0 {
r.updateStatus(stackName, "error", "no HDD data paths found for this app", time.Since(start), "")
return fmt.Errorf("no HDD data paths found for %s", stackName)
}
```
**Replace with:**
```go
args := []string{
"backup", "--repo", repoPath,
"--password-file", pwPath,
"--tag", stackName,
"--tag", "cross-drive",
}
// Include user data (HDD mounts)
args = append(args, mounts...)
// Include app config dir (compose + app.yaml + .felhom.yml)
if composePath, ok := r.stackProvider.GetStackComposePath(stackName); ok {
args = append(args, filepath.Dir(composePath))
}
// Include DB dump dir (all stacks' dumps — restic deduplicates)
if r.dbDumpDir != "" {
if _, err := os.Stat(r.dbDumpDir); err == nil {
args = append(args, r.dbDumpDir)
}
}
```
Note: for restic, including the full DB dump dir is fine — restic deduplicates and it's
shared across apps. The `--tag <stackName>` identifies which app this backup belongs to.
### 1e: Wire up in main.go
**File:** `cmd/controller/main.go`, after the existing `crossDriveRunner.SetDBDumper(backupMgr)` line:
```go
crossDriveRunner.SetDBDumpDir(cfg.Paths.DBDumpDir)
// Resolve HDD mounts for this app (may be empty for config-only apps)
mounts := r.stackProvider.GetStackHDDMounts(stackName)
```
**Why this works:** The rest of the function already handles empty mounts correctly:
- Safety overlap check: empty loop = no overlap → passes
- `runRsyncBackup`: mount loop doesn't execute, but DB + config copy still runs
- `runResticBackup`: no mount paths appended, but config dir + DB dump dir still included
- Size calculation: destDir exists and can be measured even without mount data
---
## Fix 2: Restructure UI from per-layer to per-tier
The current UI shows 3 rows per app (DB / Konfiguráció / Felhasználói adatok). This
maps to implementation details, not the customer mental model. Change to 2 rows per app:
- **1. mentés** — nightly restic (mandatory, same drive)
- **2. mentés** — cross-drive (opt-in, different drive)
### 2a: Restructure `AppBackupRow` struct
## Fix 2: Update status dot logic + remove HasHDDData gates from handlers.go
**File:** `internal/web/handlers.go`
**Replace the current `AppBackupRow` struct (lines 500533) with:**
### 2a: Update `AppBackupRow` struct comments
In the `AppBackupRow` struct, update the Tier 2 comment:
```go
// AppBackupRow holds per-tier backup information for one app on the backup page.
type AppBackupRow struct {
StackName string
DisplayName string
Status string // "green", "yellow", "red", "auto"
StatusText string // short Hungarian tooltip
// App characteristics
HasHDDData bool
HasDB bool
StorageLabel string
HDDSizeHuman string
// What this app's backup contains (for display)
// e.g., "DB + Konfiguráció + Adatok", "DB + Konfiguráció", "Konfiguráció"
BackupContents string
// Tier 1: Nightly backup (always exists)
Tier1LastRun string // formatted time of last restic snapshot
Tier1LastStatus string // "ok", "error", ""
Tier1DBStatus string // "ok", "error", "" — separate DB dump status for warning
// Tier 2: Cross-drive backup (only for apps with HDD data)
Tier2Configured bool
Tier2Method string // "rsync", "restic"
Tier2MethodLabel string // "rsync", "restic"
Tier2Dest string // destination label
Tier2Schedule string // "Naponta", "Hetente"
Tier2LastRun string
Tier2LastStatus string // "ok", "error", "running", ""
Tier2LastError string
Tier2StatusBadge string // "Sikeres", "Hiba", "Fut...", "—"
Tier2SizeHuman string
Tier2Browsable bool // true for rsync (plain files), false for restic
// Warnings accumulated for this app
Warnings []string
}
// Tier 2: Cross-drive backup (configurable for all apps)
```
### 2b: Update `buildAppBackupRows`
(Remove the old "(only for apps with HDD data)" comment.)
**File:** `internal/web/handlers.go`
### 2b: Rewrite `buildAppBackupRows` status + Tier2 section
**Replace the entire `buildAppBackupRows` function** with:
Replace the current status + Tier2 block (lines 605672):
**CURRENT CODE:**
```go
// Default status = auto (no user data, just config)
row.Status = "auto"
row.StatusText = "Automatikus mentés"
if app.HasHDDData {
cfg, hasCfg := crossConfigs[app.StackName]
// ... full Tier2 block ...
}
```
**REPLACE WITH:**
```go
// Status dot — start as yellow (1 tier only)
row.Status = "yellow"
row.StatusText = "Csak helyi mentés (1 szint)"
cfg, hasCfg := crossConfigs[app.StackName]
if !hasCfg || cfg == nil || !cfg.Enabled {
// Only Tier 1 — no second copy
row.Tier2Configured = false
} else {
row.Tier2Configured = true
row.Tier2Method = cfg.Method
row.Tier2MethodLabel = cfg.Method // "rsync" or "restic"
row.Tier2Browsable = cfg.Method == "rsync"
row.Tier2Dest = destLabels[cfg.DestinationPath]
if row.Tier2Dest == "" {
row.Tier2Dest = cfg.DestinationPath
}
switch cfg.Schedule {
case "daily":
row.Tier2Schedule = "Naponta"
case "weekly":
row.Tier2Schedule = "Hetente"
default:
row.Tier2Schedule = cfg.Schedule
}
if cfg.LastRun != "" {
if t, err := time.Parse(time.RFC3339, cfg.LastRun); err == nil {
row.Tier2LastRun = t.In(loc).Format("01-02 15:04")
}
}
row.Tier2LastStatus = cfg.LastStatus
row.Tier2LastError = cfg.LastError
row.Tier2SizeHuman = cfg.LastSizeHuman
switch cfg.LastStatus {
case "ok":
row.Tier2StatusBadge = "Sikeres"
row.Status = "green"
row.StatusText = "Mentés rendben"
case "error":
row.Tier2StatusBadge = "Hiba"
// Status stays yellow
row.StatusText = "Utolsó mentés sikertelen"
case "running":
row.Tier2StatusBadge = "Fut..."
default:
row.Tier2StatusBadge = "—"
// Tier2 configured but never run — stay yellow
}
// Destination health check — can downgrade green to yellow/red
if cfg.DestinationPath != "" {
if err := s.crossDriveRunner.ValidateDestination(cfg.DestinationPath); err != nil {
if strings.Contains(err.Error(), "does not exist") || strings.Contains(err.Error(), "not writable") {
row.Status = "red"
row.StatusText = "Mentési cél nem elérhető"
} else if row.Status != "red" {
row.Status = "yellow"
row.StatusText = "Figyelmeztetés"
}
row.Warnings = append(row.Warnings, err.Error())
}
}
}
```
Note: `s.crossDriveRunner` is used instead of `s.crossDrive` — verify the field name
in `server.go` and use whatever the actual field is called. (The current code at the
old line 657 shows `s.crossDriveRunner.ValidateDestination`.)
### 2c: Remove HasHDDData gate from cross-drive summary builder
In `backupsHandler`, the cross-drive summary loop (around line 409) has:
```go
// buildAppBackupRows constructs one AppBackupRow per deployed app for the backup page.
func (s *Server) buildAppBackupRows(
status *backup.FullBackupStatus,
crossConfigs map[string]*settings.CrossDriveBackup,
destLabels map[string]string,
) []AppBackupRow {
loc := getTimezone()
// Build DB stack lookup
dbStacks := make(map[string]bool)
for _, db := range status.DiscoveredDBs {
dbStacks[db.StackName] = true
}
for _, f := range status.DumpFiles {
dbStacks[f.StackName] = true
}
// Tier 1 timestamps (shared across all apps — single nightly job)
tier1LastRun := ""
tier1LastStatus := ""
if status.LastBackup != nil {
tier1LastRun = status.LastBackup.LastRun.In(loc).Format("01-02 15:04")
if status.LastBackup.Success {
tier1LastStatus = "ok"
} else {
tier1LastStatus = "error"
}
}
tier1DBStatus := ""
if status.LastDBDump != nil {
if status.LastDBDump.Success {
tier1DBStatus = "ok"
} else {
tier1DBStatus = "error"
}
}
var rows []AppBackupRow
for _, app := range status.AppDataInfo {
hasDB := dbStacks[app.StackName] || app.HasDBDump
// Build backup contents label
var parts []string
if hasDB {
parts = append(parts, "DB")
}
parts = append(parts, "Konfig")
if app.HasHDDData {
parts = append(parts, "Adatok")
}
contents := strings.Join(parts, " + ")
row := AppBackupRow{
StackName: app.StackName,
DisplayName: app.DisplayName,
HasHDDData: app.HasHDDData,
HasDB: hasDB,
StorageLabel: app.StorageLabel,
HDDSizeHuman: app.HDDSizeHuman,
BackupContents: contents,
Tier1LastRun: tier1LastRun,
Tier1LastStatus: tier1LastStatus,
Tier1DBStatus: tier1DBStatus,
}
// Default status = auto (no user data, just config)
row.Status = "auto"
row.StatusText = "Automatikus mentés"
if app.HasHDDData {
cfg, hasCfg := crossConfigs[app.StackName]
if !hasCfg || cfg == nil || !cfg.Enabled {
// HDD data backed up via nightly restic (mandatory), but no second copy
row.Tier2Configured = false
row.Status = "yellow"
row.StatusText = "Nincs második másolat (csak helyi mentés)"
} else {
row.Tier2Configured = true
row.Tier2Method = cfg.Method
row.Tier2MethodLabel = cfg.Method // "rsync" or "restic"
row.Tier2Browsable = cfg.Method == "rsync"
row.Tier2Dest = destLabels[cfg.DestinationPath]
if row.Tier2Dest == "" {
row.Tier2Dest = cfg.DestinationPath
}
switch cfg.Schedule {
case "daily":
row.Tier2Schedule = "Naponta"
case "weekly":
row.Tier2Schedule = "Hetente"
default:
row.Tier2Schedule = cfg.Schedule
}
if cfg.LastRun != "" {
if t, err := time.Parse(time.RFC3339, cfg.LastRun); err == nil {
row.Tier2LastRun = t.In(loc).Format("01-02 15:04")
}
}
row.Tier2LastStatus = cfg.LastStatus
row.Tier2LastError = cfg.LastError
row.Tier2SizeHuman = cfg.LastSizeHuman
switch cfg.LastStatus {
case "ok":
row.Tier2StatusBadge = "Sikeres"
case "error":
row.Tier2StatusBadge = "Hiba"
row.Status = "yellow"
row.StatusText = "Utolsó mentés sikertelen"
case "running":
row.Tier2StatusBadge = "Fut..."
default:
row.Tier2StatusBadge = "—"
}
// Destination health check
if cfg.Enabled && cfg.DestinationPath != "" {
if err := s.crossDrive.ValidateDestination(cfg.DestinationPath); err != nil {
if strings.Contains(err.Error(), "does not exist") || strings.Contains(err.Error(), "not writable") {
row.Status = "red"
row.StatusText = "Mentési cél nem elérhető"
} else {
row.Status = "yellow"
row.StatusText = "Figyelmeztetés"
}
row.Warnings = append(row.Warnings, err.Error())
} else if row.Status != "yellow" {
row.Status = "green"
row.StatusText = "Mentés rendben"
}
}
}
}
// DB dump failure warning (affects Tier 1 quality)
if hasDB && tier1DBStatus == "error" {
if row.Status != "red" {
row.Status = "yellow"
row.StatusText = "Adatbázis mentés sikertelen"
}
}
rows = append(rows, row)
}
return rows
}
for _, app := range fullStatus.AppDataInfo {
if !app.HasHDDData {
continue
}
```
**Note:** This requires `strings` import in handlers.go — check it's present.
**Remove** the `if !app.HasHDDData { continue }` — all apps participate in cross-drive summary.
### 2c: Update template to per-tier display
### 2d: Update the top-level warning logic
The "NoUserDataBackupWarning" check (around line 473) uses `HasHDDData`**keep this as-is**.
This warning is specifically about user data (photos, documents) being at risk, which only
applies to HDD apps. The status dot change already incentivizes Tier 2 for all apps.
---
## Fix 3: Update backup page template
**File:** `internal/web/templates/backups.html`
Replace the backup-layers section inside the `app-backup-row-detail` div (lines 265322).
The current code has three `backup-layer-row` divs (DB, Konfiguráció, Felhasználói adatok).
**Replace with two tier rows:**
### 3a: Remove HasHDDData gate on Tier 2 row
**Find** (around line 284):
```html
<div class="app-backup-row-detail" style="display:none">
<div class="backup-layers">
<!-- Tier 1: Nightly backup (mandatory, same drive) -->
<div class="backup-layer-row">
<span class="tier-label">1. mentés</span>
<span class="layer-badge">Auto</span>
<span class="tier-location">helyi</span>
{{if .Tier1LastRun}}
<span class="layer-last">Utolsó: {{.Tier1LastRun}}
{{if eq .Tier1LastStatus "ok"}}<span class="text-ok"></span>
{{else if eq .Tier1LastStatus "error"}}<span class="text-error"></span>{{end}}
</span>
{{end}}
<span class="tier-contents">{{.BackupContents}}</span>
{{if and .HasDB (eq .Tier1DBStatus "error")}}
<span class="text-error" style="font-size:.8rem">⚠ DB dump hiba</span>
{{end}}
</div>
<!-- Tier 2: Cross-drive backup (opt-in, different device) -->
{{if .HasHDDData}}
<div class="backup-layer-row">
<span class="tier-label">2. mentés</span>
{{if .Tier2Configured}}
<span class="layer-method">{{.Tier2MethodLabel}}</span>
<span class="layer-dest">→ {{.Tier2Dest}}</span>
<span class="layer-schedule">{{.Tier2Schedule}}</span>
{{if .Tier2LastRun}}
<span class="layer-last">Utolsó: {{.Tier2LastRun}}
<span class="{{if eq .Tier2LastStatus "ok"}}text-ok{{else if eq .Tier2LastStatus "error"}}text-error{{else if eq .Tier2LastStatus "running"}}text-muted{{end}}">
{{.Tier2StatusBadge}}
</span>
</span>
{{end}}
{{if .Tier2SizeHuman}}<span class="tier-size">{{.Tier2SizeHuman}}</span>{{end}}
<span class="tier-contents">{{.BackupContents}}</span>
{{if .Tier2Browsable}}<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben">📁</span>{{end}}
<div class="layer-actions">
<a href="/stacks/{{.StackName}}/deploy" class="btn btn-xs btn-outline">Beállítás</a>
<button class="btn btn-xs btn-outline"
onclick="triggerCrossDriveBackup('{{.StackName}}', this)">
Futtatás most</button>
</div>
{{else}}
<span class="layer-auto-ok">✓ 1. mentés auto</span>
<span class="layer-unconfigured">⚠ Nincs 2. másolat</span>
<a href="/stacks/{{.StackName}}/deploy" class="btn btn-xs">Beállítás →</a>
{{end}}
</div>
{{end}}
</div>
{{if .Warnings}}
<div class="layer-warnings">
{{range .Warnings}}
<div class="backup-layer-warning">{{.}}</div>
{{end}}
</div>
{{end}}
</div>
```
### 2d: Add CSS for new tier elements
**Remove** the `{{if .HasHDDData}}` opening and its matching `{{end}}` (around line 313).
The Tier 2 row should always be shown for all apps.
**File:** `internal/web/templates/style.css`
### 3b: Update header meta badges
Add near the existing `.layer-*` styles:
**Find** (around line 256261):
```html
{{if .HasHDDData}}
{{if .StorageLabel}}<span class="meta-badge meta-badge-storage">{{.StorageLabel}}</span>{{end}}
<span class="mono app-backup-size" style="font-size:.8rem">{{.HDDSizeHuman}}</span>
{{else}}
<span class="meta-badge">Auto</span>
{{end}}
```
```css
.tier-label {
font-weight: 600;
min-width: 5rem;
color: var(--text);
}
.tier-location {
color: var(--text-muted);
font-size: .85rem;
}
.tier-contents {
color: var(--text-muted);
font-size: .8rem;
font-style: italic;
margin-left: .25rem;
}
.tier-size {
color: var(--text-muted);
font-size: .8rem;
margin-left: .25rem;
}
.tier-browsable {
font-size: .75rem;
margin-left: .15rem;
cursor: help;
}
**Replace with:**
```html
{{if .HasHDDData}}
{{if .StorageLabel}}<span class="meta-badge meta-badge-storage">{{.StorageLabel}}</span>{{end}}
<span class="mono app-backup-size" style="font-size:.8rem">{{.HDDSizeHuman}}</span>
{{else}}
<span class="meta-badge">Konfig{{if .HasDB}} + DB{{end}}</span>
{{end}}
```
This shows what type of data the app has (instead of meaningless "Auto").
### 3c: Add Tier 3 placeholder row
After the Tier 2 `</div>` (the closing div of the tier-2 backup-layer-row), add:
```html
<!-- Tier 3: Remote backup (future) -->
<div class="backup-layer-row" style="opacity:.5">
<span class="tier-label">3. mentés</span>
<span class="layer-badge" style="background:var(--bg-tertiary);color:var(--text-muted)">Hamarosan</span>
<span class="tier-location">távoli (offsite)</span>
<span class="tier-contents" style="font-style:normal;color:var(--text-muted)">B2 / S3 / SFTP — hamarosan elérhető</span>
</div>
```
### 3d: Update "Run all" button text
**Find** (around line 328):
```html
<button class="btn btn-sm btn-outline" onclick="triggerAllCrossDrive(this)">Összes HDD mentés futtatása most</button>
```
**Replace with:**
```html
<button class="btn btn-sm btn-outline" onclick="triggerAllCrossDrive(this)">Összes 2. mentés futtatása most</button>
```
---
## Fix 3: Clean up unused code
## Fix 4: Deploy page — show cross-drive config for all deployed apps
### 3a: Remove unused `filterSnapshotsByPaths` and `pathCovers`
**File:** `internal/web/templates/deploy.html`
**File:** `internal/api/router.go`
### 4a: Remove StorageInfo gate from cross-drive section
Delete the `filterSnapshotsByPaths` function and the `pathCovers` helper — they were
left after v0.12.7a removed the call site. No other code references them.
The cross-drive backup config section is currently double-gated (lines 95220):
### 3b: Remove `VolumeLastRun` / `VolumeLastStatus` / `DBLastRun` / `DBLastStatus` fields
```html
{{if .AlreadyDeployed}}
{{if .StorageInfo}} ← THIS IS THE GATE — remove it
<div class="deploy-cross-drive">
...
</div>
{{end}} ← remove matching end
{{end}}
```
These fields no longer exist in the new `AppBackupRow` struct (replaced by Tier1/Tier2
fields). Verify no template references to the old field names remain.
**Change to** (keep only the AlreadyDeployed gate):
```html
{{if .AlreadyDeployed}}
<div class="deploy-cross-drive">
...
</div>
{{end}}
```
### 4b: Update section text for generality
**Find** (line 109):
```html
<p style="font-weight:500;margin-bottom:1rem">Másolat másik meghajtóra (felhasználói adatok):</p>
```
**Replace with:**
```html
<p style="font-weight:500;margin-bottom:1rem">2. mentés — másolat másik meghajtóra:</p>
```
**Find** (line 214216):
```html
<div class="form-hint" style="margin-top:.75rem;color:var(--text-muted)">
A cél meghajtó legyen más fizikai eszköz, mint az alkalmazás adattárolója.
</div>
```
**Replace with:**
```html
<div class="form-hint" style="margin-top:.75rem;color:var(--text-muted)">
A cél meghajtó legyen más fizikai eszköz a meghibásodás elleni védelem érdekében.
</div>
```
---
@@ -540,108 +319,86 @@ fields). Verify no template references to the old field names remain.
| Fix | What | File(s) |
|-----|------|---------|
| 1a | `dbDumpDir` field + setter on CrossDriveRunner | `crossdrive.go` |
| 1b | `copyStackDBDumps` helper for rsync | `crossdrive.go` |
| 1c | rsync includes DB dumps + config | `crossdrive.go` |
| 1d | restic includes config dir + DB dump dir | `crossdrive.go` |
| 1e | Wire `SetDBDumpDir` in main.go | `main.go` |
| 2a | `AppBackupRow` restructured to per-tier | `handlers.go` |
| 2b | `buildAppBackupRows` rewritten for per-tier | `handlers.go` |
| 2c | Template: 2 tier rows instead of 3 layer rows | `backups.html` |
| 2d | CSS for tier elements | `style.css` |
| 3a | Remove unused `filterSnapshotsByPaths` + `pathCovers` | `router.go` |
| 3b | Verify no old field references remain | `backups.html` |
| 1 | Remove `len(mounts) == 0` error gate | `crossdrive.go` |
| 2a | Update `AppBackupRow` Tier2 comment | `handlers.go` |
| 2b | Rewrite status + Tier2 block (remove HasHDDData gate, new dot logic) | `handlers.go` |
| 2c | Remove HasHDDData gate from cross-drive summary | `handlers.go` |
| 3a | Remove `{{if .HasHDDData}}` around Tier 2 row | `backups.html` |
| 3b | Update meta badges ("Auto" → "Konfig + DB") | `backups.html` |
| 3c | Add Tier 3 placeholder row | `backups.html` |
| 3d | Rename "Összes HDD mentés" → "Összes 2. mentés" | `backups.html` |
| 4a | Remove `{{if .StorageInfo}}` gate from cross-drive section | `deploy.html` |
| 4b | Update cross-drive section text for generality | `deploy.html` |
## Files to modify (6)
## Files to modify (4)
1. `internal/backup/crossdrive.go` — Fix 1a + 1b + 1c + 1d
2. `cmd/controller/main.go` — Fix 1e
3. `internal/web/handlers.go` — Fix 2a + 2b
4. `internal/web/templates/backups.html` — Fix 2c
5. `internal/web/templates/style.css` — Fix 2d
6. `internal/api/router.go` — Fix 3a
1. `internal/backup/crossdrive.go` — Fix 1
2. `internal/web/handlers.go` — Fix 2a + 2b + 2c
3. `internal/web/templates/backups.html` — Fix 3a + 3b + 3c + 3d
4. `internal/web/templates/deploy.html` — Fix 4a + 4b
## Status dot logic after fix
| Dot color | Meaning |
|-----------|---------|
| Green | 2+ tiers with successful backups + destination healthy |
| Yellow | Only 1 tier, or Tier 2 failing, or Tier 2 configured but never run |
| Red | Tier 2 destination blocked/inaccessible |
**"auto" (gray) is removed.** Every app now shows yellow or better.
## Architecture after fix
```
Per-app backup tiers:
┌──────────────────────────────────────────────────────┐
TIER 1 — Nightly restic (MANDATORY, same drive)
Contains:
- DB dumps (pg_dump / mariadb-dump)
- Config (compose.yml, app.yaml, .felhom.yml)
- User data (ALL HDD bind mounts — mandatory)
│ │
│ Protects against: accidental deletion, corruption │
│ Does NOT protect against: drive failure │
│ │
├──────────────────────────────────────────────────────┤
│ TIER 2 — Cross-drive backup (OPT-IN, second device) │
│ │
│ Contains (COMPLETE — same as Tier 1): │
│ - DB dumps (copied to _db/ subfolder) │
│ - Config (rsynced to _config/ subfolder) │
│ - User data (rsync or restic to destination) │
│ │
│ rsync layout: │
│ backups/rsync/<app>/ │
│ _db/ ← DB dump files (browsable) │
│ _config/ ← compose + app.yaml (browsable) │
│ <data> ← user data (browsable) │
│ │
│ restic layout: │
│ backups/restic/ ← encrypted repo (not browsable) │
│ │
│ Protects against: drive failure, drive theft │
│ │
├──────────────────────────────────────────────────────┤
│ TIER 3 — Remote backup (FUTURE) │
│ │
│ Complete offsite copy. Not implemented yet. │
└──────────────────────────────────────────────────────┘
Per-app Tier 2 availability:
┌──────────────────────────────────────────────────────────
App type │ Tier 1 │ Tier 2 (new)
───────────────────│───────────────────│──────────────────
HDD + DB │ Config+DB+Data │ Config+DB+Data
HDD, no DB │ Config+Data │ Config+Data
DB, no HDD │ Config+DB │ Config+DB (new!)
Config only │ Config │ Config (new!)
└──────────────────────────────────────────────────────────┘
UI per-app display:
┌─────────────────────────────────────────────────────────┐
Immich Külső tárhely (hdd_1) 63.9 MB │
│ 1. mentés Auto helyi 02-18 03:00 ✓ DB+Konfig+Adat│
│ 2. mentés rsync → hdd_1 Naponta 02-18 10:48 ✓
DB+Konfig+Adatok 📁 [Beáll][Futtás]
├─────────────────────────────────────────────────────────┤
Mealie Auto
│ 1. mentés Auto helyi 02-18 03:00 ✓ Konfig │
├─────────────────────────────────────────────────────────┤
● Paperless-ngx hdd_placeholder 76 B
│ 1. mentés Auto helyi 02-18 03:00 ✓ Konfig+Adatok │
2. mentés ✓ 1. mentés auto ⚠ Nincs 2. másolat
└─────────────────────────────────────────────────────────┘
UI per-app display after fix:
┌─────────────────────────────────────────────────────────────
🟢 Immich Külső tárhely (hdd_1) 63.9 MB │
│ 1. mentés Auto helyi 02-18 03:00 ✓ DB+Konfig+Adatok
│ 2. mentés rsync → hdd_1 Naponta Sikeres 📁
3. mentés Hamarosan távoli (offsite)
├─────────────────────────────────────────────────────────────
🟡 Mealie Konfig + DB
│ 1. mentés Auto helyi 02-18 03:00 ✓ DB+Konfig
│ 2. mentés ✓ 1. mentés auto ⚠ Nincs 2. másolat │
3. mentés Hamarosan távoli (offsite)
├─────────────────────────────────────────────────────────────┤
🟡 Gokapi Konfig
│ 1. mentés Auto helyi 02-18 03:00 ✓ Konfig │
│ 2. mentés ⚠ Nincs 2. másolat [Beállítás →] │
│ 3. mentés Hamarosan távoli (offsite) │
└─────────────────────────────────────────────────────────────┘
```
## Post-fix checklist
- [ ] `go build ./...` passes
- [ ] `go vet ./...` passes
- [ ] Verify no references to old fields: `DBLastRun`, `DBLastStatus`, `VolumeLastRun`,
`VolumeLastStatus`, `HasUserData`, `UserDataConfigured`, `UserDataMethod`,
`UserDataDest`, `UserDataSchedule`, `UserDataLastRun`, `UserDataLastStatus`,
`UserDataLastError`, `UserDataStatusBadge`
- [ ] Verify `filterSnapshotsByPaths` and `pathCovers` deleted from router.go
- [ ] Update `CHANGELOG.md` — session 45, version **v0.12.8**:
- Cross-drive backup now includes DB dumps + app config (complete backup)
- rsync layout: `_db/` and `_config/` subdirs alongside user data
- restic cross-drive includes config dir + DB dump dir
- UI: restructured from per-layer to per-tier display (1. mentés / 2. mentés)
- UI: shows backup contents per app (DB + Konfig + Adatok)
- UI: rsync backups show browsable indicator (📁)
- Cleanup: removed unused filterSnapshotsByPaths code
- [ ] Verify no references to old "auto" status remain in handlers.go
- [ ] Verify no template references to removed `{{if .HasHDDData}}` gate on Tier 2
- [ ] Update `CHANGELOG.md` — session 46, version **v0.12.9**:
- Tier 2 cross-drive backup now configurable for ALL apps (not just HDD apps)
- Non-HDD apps back up config + DB dumps to secondary drive
- Status dot: removed "auto" (gray) — all apps start yellow, green requires 2+ tiers
- Tier 3 placeholder row shown in UI
- Deploy page: cross-drive config form visible for all deployed apps
- Updated button text "Összes 2. mentés futtatása most"
- [ ] Commit, build on 192.168.0.180, deploy on 192.168.0.162
- [ ] Verify with `docker ps` and `docker logs`
- [ ] After deploy, verify:
- Immich card shows "1. mentés" and "2. mentés" rows (not DB/Konfig/User data rows)
- Immich Tier 2 shows "DB + Konfig + Adatok" contents label
- Run manual cross-drive backup for Immich
- Check destination: `ls backups/rsync/immich/` should show `_db/`, `_config/`, and user data
- Verify `_db/` contains `immich_postgres.sql`
- Verify `_config/` contains `docker-compose.yml`, `app.yaml`
- Mealie shows only "1. mentés" row (no Tier 2 — no HDD data)
- Paperless-ngx shows yellow dot with "⚠ Nincs 2. másolat"
- Immich: green dot (Tier 2 configured + successful backup)
- Mealie: yellow dot with "Csak helyi mentés (1 szint)"
- Mealie: Tier 2 row shown with "⚠ Nincs 2. másolat" + "Beállítás →" link
- Mealie deploy page: cross-drive config form visible
- Configure Tier 2 for Mealie → run manual backup → verify dot turns green
- Tier 3 placeholder row shown for all apps (grayed out "Hamarosan")
- Gokapi: yellow dot, Tier 2 configurable