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
+17 -9
View File
@@ -24,9 +24,17 @@ Claude in Chrome extension is available — can be used to test web UI on demo-f
- Add debug capabilities (logging, verbose output) for easier troubleshooting
- If you need more input or troubleshooting command output, ask first — don't guess
## Environment
| Machine | OS | IP | Purpose |
|---------|----|----|---------|
| **Local (this machine)** | Windows 11 | — | Development, Claude Code runs here. Repos in `E:\git\` |
| **Build server (k3s, infra)** | Debian 13 | 192.168.0.180 | Build + push container images, k3s cluster |
| **Demo node** | Debian 13 | 192.168.0.162 | Test deployment (demo-felhom.eu) |
## Workspace layout
Claude Code runs on Windows. The working directory is `E:\git\` (mapped as `/e/git/` in Git Bash).
Claude Code runs on Windows 11. The working directory is `E:\git\` (mapped as `/e/git/` in Git Bash).
This repo is at:
```
@@ -84,17 +92,17 @@ SSH=/c/Windows/System32/OpenSSH/ssh.exe
All SSH commands in this file use `$SSH` — set it at the start of your session or
substitute the full path manually.
| Host | IP | User | Role |
|------|----|------|------|
| Build server | 192.168.0.180 | kisfenyo | Build + push container images |
| Demo node | 192.168.0.162 | kisfenyo | Test deployment (demo-felhom.eu) |
| Host | OS | IP | User | Role |
|------|----|----|------|------|
| Build server | Debian 13 | 192.168.0.180 | kisfenyo | Build + push container images |
| Demo node | Debian 13 | 192.168.0.162 | kisfenyo | Test deployment (demo-felhom.eu) |
## Test environments
| Node | Hardware | Domain | IP | Notes |
|------|----------|--------|----|-------|
| demo-felhom | Acemagic N100, 16G RAM, 512G SSD + 1TB HDD | demo-felhom.eu | 192.168.0.162 | Primary test node, Cloudflare Tunnel |
| pi-customer-1 | Raspberry Pi 3B+, 1G RAM, 32G SD | pi-customer-1.local | 192.168.0.161 | Secondary test, not yet active |
| Node | OS | Hardware | Domain | IP | Notes |
|------|-----|----------|--------|----|-------|
| demo-felhom | Debian 13 | Acemagic N100, 16G RAM, 512G SSD + 1TB HDD | demo-felhom.eu | 192.168.0.162 | Primary test node, Cloudflare Tunnel |
| pi-customer-1 | Debian 13 | Raspberry Pi 3B+, 1G RAM, 32G SD | pi-customer-1.local | 192.168.0.161 | Secondary test, not yet active |
- Pi-hole DNS on local network forwards `*.demo-felhom.eu` → 192.168.0.162
- External access via Cloudflare Tunnel → Traefik reverse proxy
+250 -493
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,333 +14,101 @@ 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
// 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)
}
```
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...)
```
**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
// 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]
// ... 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 {
// HDD data backed up via nightly restic (mandatory), but no second copy
// Only Tier 1 — 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
@@ -369,170 +137,181 @@ func (s *Server) buildAppBackupRows(
switch cfg.LastStatus {
case "ok":
row.Tier2StatusBadge = "Sikeres"
row.Status = "green"
row.StatusText = "Mentés rendben"
case "error":
row.Tier2StatusBadge = "Hiba"
row.Status = "yellow"
// 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
if cfg.Enabled && cfg.DestinationPath != "" {
if err := s.crossDrive.ValidateDestination(cfg.DestinationPath); err != nil {
// 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 {
} else if row.Status != "red" {
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
}
```
**Note:** This requires `strings` import in handlers.go — check it's present.
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: Update template to per-tier display
### 2c: Remove HasHDDData gate from cross-drive summary builder
In `backupsHandler`, the cross-drive summary loop (around line 409) has:
```go
for _, app := range fullStatus.AppDataInfo {
if !app.HasHDDData {
continue
}
```
**Remove** the `if !app.HasHDDData { continue }` — all apps participate in cross-drive summary.
### 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>
```
**Remove** the `{{if .HasHDDData}}` opening and its matching `{{end}}` (around line 313).
The Tier 2 row should always be shown for all apps.
### 3b: Update header meta badges
**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="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>
<span class="meta-badge">Auto</span>
{{end}}
</div>
{{end}}
</div>
{{if .Warnings}}
<div class="layer-warnings">
{{range .Warnings}}
<div class="backup-layer-warning">{{.}}</div>
{{end}}
</div>
```
**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>
```
### 2d: Add CSS for new tier elements
### 3d: Update "Run all" button text
**File:** `internal/web/templates/style.css`
**Find** (around line 328):
```html
<button class="btn btn-sm btn-outline" onclick="triggerAllCrossDrive(this)">Összes HDD mentés futtatása most</button>
```
Add near the existing `.layer-*` styles:
```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
<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 │
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
+42 -21
View File
@@ -4,7 +4,7 @@
A single, lightweight Go container that replaces Portainer + scattered systemd scripts with a unified, Hungarian-language web dashboard for managing Docker Compose stacks, backups, storage, monitoring, and notifications on customer hardware.
**Current version: v0.12.7**
**Current version: v0.12.9**
---
@@ -177,8 +177,19 @@ self-sufficient backup** — any single tier can fully restore an app.
automatically. There is no per-app toggle.
- Each tier includes **everything** needed to restore: DB dumps, config, and user data.
No tier depends on another tier's data.
- **Tier 2 is configurable for ALL apps** — not just apps with HDD data. Non-HDD apps
back up config + DB dumps to the secondary drive (small but protects against drive failure).
- The `AppBackupPrefs.Enabled` field in settings.json is legacy and not read by any code.
**Per-app Tier 2 contents by app type:**
| App type | Tier 2 contents | Example |
|----------|----------------|---------|
| HDD + DB | Config + DB + User data | Immich, Paperless-ngx |
| HDD, no DB | Config + User data | — |
| DB, no HDD | Config + DB | Mealie, Vikunja |
| Config only | Config | Gokapi, Homepage |
#### Tier 1: Nightly Backup (mandatory, same drive)
The nightly backup has two phases that run sequentially:
@@ -208,13 +219,16 @@ Does NOT protect against drive failure (backup is on the same physical drive).
#### Tier 2: Cross-Drive Backup (opt-in, different device) (`internal/backup/crossdrive.go`)
**Complete backup** to a different physical drive — DB dumps + config + user data.
**Complete backup** to a different physical drive. Available for **all apps** — apps with HDD
data back up config + DB + user data; apps without HDD back up config + DB dumps only.
- **Two methods:**
- **rsync** — Simple mirror with `--delete` (fast, no versioning, **browsable** on disk)
- **restic** — Versioned, deduplicated, encrypted (shared repo across apps, not browsable)
- Per-app configuration in settings.json: destination path, method, schedule (daily/weekly/manual)
- **Pre-backup DB dump:** `DumpStackDB()` runs fresh pg_dump/mariadb-dump before each cross-drive backup; non-fatal on failure (wired via `DBDumper` interface to avoid circular imports)
- **Empty mounts allowed:** `RunAppBackup` accepts apps with no HDD mounts — the rsync
mount loop simply doesn't execute, but DB + config copy still runs
- **Drive-type-aware validation** (`ValidateDestination`):
| Destination type | Space checks |
@@ -227,12 +241,13 @@ Does NOT protect against drive failure (backup is on the same physical drive).
backups/rsync/<app>/
_db/ ← DB dump files (stackName_postgres.sql, etc.)
_config/ ← compose.yml, app.yaml, .felhom.yml
<user data> ← HDD mount contents (single mount: flat; multi-mount: leaf subfolders)
<user data> ← HDD mount contents (only for apps with HDD data)
```
- DB dump files excluded from user data rsync (`--exclude backups/*.sql.gz/sql/dump`) to avoid duplicating app-internal dumps
- `_` prefix directories prevent collision with user data
- **Restic backup paths:** includes HDD mounts + config dir + DB dump dir (deduplication handles overlap)
- Safety guards: destination ≠ source, path-overlap check, writable check
- For non-HDD apps, only `_db/` and `_config/` are present (no user data directory)
- **Restic backup paths:** includes HDD mounts (if any) + config dir + DB dump dir (deduplication handles overlap)
- Safety guards: destination ≠ source, path-overlap check (HDD mounts only), writable check
- **Chained execution:** runs immediately after nightly restic — daily apps every night, weekly apps on Sundays
- Per-app concurrency lock prevents overlapping runs
- Status (last_run, duration, size, error) persisted to settings.json
@@ -242,6 +257,7 @@ Does NOT protect against drive failure (backup is on the same physical drive).
#### Tier 3: Remote Backup (future)
Complete offsite backup for disaster recovery. Not yet implemented.
Placeholder shown in UI ("3. mentés — Hamarosan").
#### Restore (`internal/backup/restore.go`)
@@ -250,18 +266,18 @@ All deployed apps appear in the restore dropdown — every app has restic snapsh
| App type | Config restored | DB restored | User data restored |
|----------|----------------|------------|-------------------|
| Has HDD data | ✓ | ✓ | ✓ (always — backup is mandatory) |
| DB only, no HDD | ✓ | ✓ | n/a |
| No DB, no HDD | | — | n/a |
| Has HDD data | Yes | Yes | Yes (always — backup is mandatory) |
| DB only, no HDD | Yes | Yes | n/a |
| No DB, no HDD | Yes | — | n/a |
- **Snapshot API** returns ALL snapshots unfiltered — older snapshots still allow config+DB restore; `RestoreApp` extracts whatever paths are available
- **Restore type info** shown per-app when selected in dropdown (Hungarian banners):
- Has HDD: "Teljes visszaállítás: adatbázis + konfiguráció + felhasználói adatok"
- Has DB, no HDD: "Adatbázis és konfiguráció visszaállítása"
- No DB, no HDD: "Csak konfiguráció visszaállítása"
- Has HDD: "Teljes visszaállitas: adatbazis + konfiguracio + felhasznaloi adatok"
- Has DB, no HDD: "Adatbazis es konfiguracio visszaallitasa"
- No DB, no HDD: "Csak konfiguracio visszaallitasa"
- **Execution flow:** stop app → `restic restore <id> --target / --include <path>...` → restart app
- Running flag prevents concurrent backup/restore operations
- Snapshot ID validated (864 lowercase hex)
- Snapshot ID validated (8-64 lowercase hex)
**Note:** Restore currently uses Tier 1 (primary restic repo) only. Restoring from Tier 2
(cross-drive) is a future enhancement.
@@ -274,16 +290,18 @@ Unified per-app status table with expandable rows showing **per-tier** backup st
| Dot color | Meaning |
|-----------|---------|
| Green | Fully covered — cross-drive configured and last run OK |
| Yellow | Warning — no second copy, or last backup failed, or disk space issue |
| Red | Cross-drive destination blocked or inaccessible |
| Gray (auto) | No user data — only config/DB backup (automatic) |
| Green | 2+ tiers configured 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 or inaccessible |
**Per-app backup tiers:**
- **1. mentés** (Tier 1, always present) — Auto badge + "helyi" + last run + contents (e.g., "DB + Konfig + Adatok")
- **2. mentés** (Tier 2, only for apps with HDD data) — one of:
- Configured: method (rsync/restic) + destination + schedule + last run + status + contents + browsable indicator (📁 for rsync) + action buttons
- Not configured: "✓ 1. mentés auto" + "⚠ Nincs 2. másolat" + settings link
Every app starts as yellow (1 tier only). Green requires Tier 2 configured with successful backup.
**Per-app backup tiers (3 rows per app):**
- **1. mentes** (Tier 1, always present) — Auto badge + "helyi" + last run + contents (e.g., "DB + Konfig + Adatok")
- **2. mentes** (Tier 2, configurable for ALL apps) — one of:
- Configured: method (rsync/restic) + destination + schedule + last run + status + contents + browsable indicator (folder icon for rsync) + action buttons
- Not configured: "1. mentes auto" + "Nincs 2. masolat" + settings link
- **3. mentes** (Tier 3, placeholder) — grayed out "Hamarosan" + "tavoli (offsite)" + future note
**Backup contents per app** (shown per tier):
- Apps with DB + HDD: "DB + Konfig + Adatok"
@@ -291,6 +309,9 @@ Unified per-app status table with expandable rows showing **per-tier** backup st
- Apps with HDD, no DB: "Konfig + Adatok"
- Apps with neither: "Konfig"
**Deploy page** shows cross-drive (Tier 2) configuration form for **all deployed apps**,
not just those with HDD data. Non-HDD apps can configure destination, method, and schedule.
**Other sections:**
- Schedule overview with next run times for DB dump, restic, prune
- Snapshot history table (last 20 snapshots with ID, time, files new/changed, data added)