v0.14.0: Per-drive backup architecture + storage path overhaul

Major refactor of backup and storage paths:

- Per-drive restic repos at <drive>/backups/primary/restic/
- Per-app DB dumps at <drive>/backups/primary/<app>/db-dumps/
- Remove global BackupDir, DBDumpDir, ResticRepo config fields
- Add SystemDataPath config (fallback for apps without HDD)
- New backup/paths.go with pure path computation helpers
- Add GetStackHDDPath to StackDataProvider interface
- Restic methods now accept repoPath as parameter
- Cross-drive backup uses new secondary path structure
- Rename storage/ to appdata/ in scripts and compose templates
- Update protected HDD paths (storage → appdata + backups)
- Simplify backup UI (remove global path displays)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 18:47:39 +01:00
parent 05f6095e6b
commit 563c9515d9
15 changed files with 937 additions and 570 deletions
+432 -247
View File
@@ -1,319 +1,504 @@
# TASK.md — v0.13.1 UI Polish Fixes (Round 2)
# TASK.md — v0.14.0 Storage & Backup Architecture Overhaul
**Version:** v0.13.1
**Type:** UI polish — 4 fixes
**Files likely affected:** `deploy.html`, `backups.html`, `dashboard.html`, `monitoring.html`, `layout.html`, `style.css`, `handlers.go`, `alerts.go`, `funcmap.go`
Read `CLAUDE.md`, `controller/README.md`, and `CONTEXT.md` before starting.
**Version:** v0.14.0
**Type:** Architecture overhaul — storage paths, backup structure, multi-drive support
**Scope:** Controller Go code + app catalog compose files + setup scripts
**Note:** Demo node will be reinstalled from scratch — no migration needed
---
## Fix 1: Backup section on deploy page needs card/box styling
## Design Overview
**Problem:** The "Biztonsági mentés" section on app deploy/settings pages (e.g., `/stacks/immich/deploy`) has no visible border or card styling, unlike the "Adattárolás" section above it and other card sections on the page. It looks flat and out of place.
### New directory structure (per drive)
**Where:** `deploy.html` line ~96: `<div class="deploy-cross-drive">` and the `.deploy-cross-drive` CSS class in `style.css`.
Every drive mount (`/mnt/sys_drive`, `/mnt/hdd_1`, `/mnt/hdd_2`, ...) uses the same layout:
**Solution:** Add card-like styling to `.deploy-cross-drive` in `style.css`. Look at how `.deploy-storage-info` (the "Adattárolás" card above it on the same page) is styled and match it:
- `border: 1px solid var(--border)`
- `background: var(--card-bg)`
- `border-radius: 12px`
- `padding: 1.5rem`
```
/mnt/<drive>/
appdata/<app>/ ← live app data (renamed from "storage")
backups/
primary/
<app>/db-dumps/ ← raw DB dumps per app (accessible for testing)
restic/ ← per-drive restic repo (all apps on this drive)
secondary/
<app>/rsync/ ← rsync copies from apps on OTHER drives
restic/ ← restic repo for secondary copies
Dokumentumok/
media/
Download/
movies/
series/
music/
audiobooks/
```
If `.deploy-cross-drive` already exists in CSS, add the missing border/background properties. If it doesn't exist, create it with the above properties.
### Key rules
1. **An app's "home drive"** = the drive from its `HDD_PATH` env var, or `cfg.Paths.SystemDataPath` if no HDD_PATH
2. **Primary backup** lives on the SAME drive as the app — protects against accidental deletion, app bugs
3. **Secondary backup** lives on a DIFFERENT drive — protects against drive failure
4. **One restic repo per drive** (in both primary and secondary) — same password for all repos
5. **DB dumps** are raw SQL files per-app, always on the app's home drive, also included in restic
6. **Compose configs + controller.yaml** go into EVERY primary restic repo (small, ensures self-contained restore)
7. **`storage/``appdata/`** rename across all compose templates
8. **Filebrowser** mounts per-drive subdirectories: `media/`, `Dokumentumok/`, `backups/secondary/` (for file recovery)
---
## Fix 2: Clean up auto-generated env values section on deploy page
## Phase 1: Config & path helpers
**Problem:** The "Automatikusan generált értékek" section on the deploy page is cluttered. Each field currently shows: input + "Megjelenítés" button (for passwords) + "Másolás" button + badge. Too many elements stacked together.
### 1a. `internal/config/config.go`
**Current code location:** `deploy.html` lines ~254-281, inside the `{{if .AutoFields}}` block.
**Add:**
- `SystemDataPath string \`yaml:"system_data_path"\`` to `PathsConfig` — default `/mnt/sys_drive`
**Current structure per field:**
```html
<div class="form-group form-group-auto">
<label>{{.Label}}</label>
{{if and $isDeployed $val}}
{{if eq .Type "secret"}}
<div class="input-with-button">
<input type="password" ...>
<button ... onclick="toggleAutoField(...)">Megjelenítés</button>
<button ... onclick="copyAutoField(...)">Másolás</button>
</div>
{{else}}
<div class="input-with-button">
<input type="text" ...>
<button ... onclick="copyAutoField(...)">Másolás</button>
</div>
{{end}}
{{end}}
<span class="auto-generated-badge">✓ Automatikusan generálva</span>
</div>
```
**Remove from struct:**
- `BackupDir string` from PathsConfig
- `DBDumpDir string` from PathsConfig
- `ResticRepo string` from BackupConfig
**Changes:**
**Keep:**
- `ResticPasswordFile string` in BackupConfig (shared across all repos)
- `HDDPath string` in PathsConfig (legacy, still used as default storage)
1. **Remove ALL "Másolás" buttons** — users can select+copy natively, dedicated button adds clutter
2. **Keep** the "Megjelenítés"/"Elrejtés" toggle button for secret/password fields (essential)
3. **Move the badge inline with the label** — instead of on a separate line below the input, put it next to the label text
**Update `applyDefaults()`:**
- Remove: `d(&cfg.Paths.BackupDir, "/srv/backups")`
- Remove: `d(&cfg.Paths.DBDumpDir, "/srv/backups/db-dumps")`
- Remove: `d(&cfg.Backup.ResticRepo, "/srv/backups/restic-repo")`
- Add: `d(&cfg.Paths.SystemDataPath, "/mnt/sys_drive")`
**New structure per field:**
```html
<div class="form-group form-group-auto">
<label>{{.Label}} <span class="auto-generated-badge">✓ Automatikusan generálva</span></label>
{{if and $isDeployed $val}}
{{if eq .Type "secret"}}
<div class="input-with-button">
<input type="password" id="auto-field-{{.EnvVar}}" class="form-control" value="{{$val}}" readonly>
<button type="button" class="btn btn-sm btn-outline" onclick="toggleAutoField('auto-field-{{.EnvVar}}', this)">Megjelenítés</button>
</div>
{{else}}
<input type="text" id="auto-field-{{.EnvVar}}" class="form-control" value="{{$val}}" readonly>
{{end}}
{{end}}
</div>
```
**Gotcha:** All code referencing `cfg.Paths.BackupDir`, `cfg.Paths.DBDumpDir`, `cfg.Backup.ResticRepo` will break. Grep for all references and update.
4. **Remove the `copyAutoField` JS function** — search for `function copyAutoField` in deploy.html's `<script>` block and remove it entirely.
### 1b. New file: `internal/backup/paths.go`
5. **CSS adjustments** in `style.css`:
- `.form-group-auto label` should use `display: flex; align-items: center; gap: 0.5rem;` so the badge sits inline with the label text
- `.auto-generated-badge` should be `font-size: 0.75rem` and `font-weight: normal` to not overpower the label
- Read-only inputs (`.form-group-auto .form-control[readonly]`) could have a slightly muted background (e.g., `background: var(--bg-secondary)`) to visually indicate they're not editable
---
## Fix 3: Pillanatképek — show 0 instead of n/a
**Problem:** In the Pillanatképek table on the backup page, columns "Hozzáadott (új adat)", "Új fájl", "Változott" show `n/a` with a long tooltip when `HasStats` is false. The user wants `0` instead — `n/a` implies "data unavailable", but `0` correctly means "no changes".
**Current code** (`backups.html`, in the snapshot table tbody):
```html
<td class="mono">{{if .HasStats}}+{{.DataAdded}}{{else}}<span class="col-na" title="A restic pillanatképek nem tartalmaznak méretadatot — csak az utolsó mentés adatai állnak rendelkezésre.">n/a</span>{{end}}</td>
<td class="mono">{{if .HasStats}}{{.FilesNew}}{{else}}<span class="col-na" title="A restic pillanatképek nem tartalmaznak fájlszámot — csak az utolsó mentés adatai állnak rendelkezésre.">n/a</span>{{end}}</td>
<td class="mono">{{if .HasStats}}{{.FilesChanged}}{{else}}<span class="col-na" title="A restic pillanatképek nem tartalmaznak fájlszámot — csak az utolsó mentés adatai állnak rendelkezésre.">n/a</span>{{end}}</td>
```
**Replace with:**
```html
<td class="mono">{{if .HasStats}}+{{.DataAdded}}{{else}}0{{end}}</td>
<td class="mono">{{if .HasStats}}{{.FilesNew}}{{else}}0{{end}}</td>
<td class="mono">{{if .HasStats}}{{.FilesChanged}}{{else}}0{{end}}</td>
```
Remove the `<span class="col-na" title="...">` wrappers entirely. Simple `0` values.
Also remove the `.col-na` CSS class from `style.css` if it's no longer used elsewhere.
---
## Fix 4: Move disk warnings from top banner to inline under storage bars
**Problem:** The yellow HDD warning banner ("Az adattároló nem külön meghajtón van...") appears at the very top of the page via `layout.html`, rendered as a full-width alert banner. Even though v0.13.0 added `PageOnly` filtering (only dashboard + monitoring), the warnings are still too prominent and positioned at the top rather than near the relevant storage info.
**Goal:**
1. Disk warnings should NOT appear in the layout.html top banner at all
2. They should render **inline, under the relevant storage usage bar** on dashboard and monitoring pages
3. They should be **smaller and more subtle** than the current full-width alert banner
### Step 1: Add `Inline` field to Alert struct
In `alerts.go`, add an `Inline bool` field to the `Alert` struct:
Path computation helpers (pure functions, no state):
```go
type Alert struct {
ID string
Level string
Message string
Link string
LinkText string
PageOnly []string
Inline bool // If true, rendered by page template, not layout
package backup
import "path/filepath"
func PrimaryBackupPath(drivePath string) string {
return filepath.Join(drivePath, "backups", "primary")
}
func PrimaryResticRepoPath(drivePath string) string {
return filepath.Join(drivePath, "backups", "primary", "restic")
}
func AppDBDumpPath(drivePath, stackName string) string {
return filepath.Join(drivePath, "backups", "primary", stackName, "db-dumps")
}
func SecondaryBackupPath(drivePath string) string {
return filepath.Join(drivePath, "backups", "secondary")
}
func AppSecondaryRsyncPath(drivePath, stackName string) string {
return filepath.Join(drivePath, "backups", "secondary", stackName, "rsync")
}
func SecondaryResticRepoPath(drivePath string) string {
return filepath.Join(drivePath, "backups", "secondary", "restic")
}
func AppDataPath(drivePath, stackName string) string {
return filepath.Join(drivePath, "appdata", stackName)
}
```
### Step 2: Mark disk warnings as inline in Refresh()
### 1c. App drive resolution
In `alerts.go` `Refresh()`, where disk warnings get `PageOnly` set (the block checking for "meghajtón" / "adattároló" / "meghajtó"), also set `Inline: true`:
Need a method to determine which drive an app lives on. Add to the backup Manager or StackDataProvider:
```go
if strings.Contains(w, "meghajtón") || strings.Contains(w, "adattároló") || strings.Contains(w, "meghajtó") {
alert.ID = "disk-not-separate"
alert.PageOnly = []string{"dashboard", "monitoring"}
alert.Inline = true
// GetAppDrivePath returns the drive path for an app.
// Uses HDD_PATH from app.yaml if set, otherwise falls back to system data path.
func (m *Manager) GetAppDrivePath(stackName string) string {
if mounts := m.stackProvider.GetStackHDDMounts(stackName); len(mounts) > 0 {
// The HDD_PATH is the mount point — extract the drive from the first mount
// e.g., /mnt/hdd_1/appdata/immich → /mnt/hdd_1
// Actually, we need the HDD_PATH itself, not the mounts
}
return m.systemDataPath
}
```
### Step 3: Skip inline alerts in layout.html
**Gotcha:** `GetStackHDDMounts` returns resolved mount paths (e.g., `/mnt/hdd_1/appdata/immich`), not the raw `HDD_PATH` value. Need a way to get the raw HDD_PATH for a stack. Options:
- Add `GetStackHDDPath(name string) string` to `StackDataProvider` interface
- Or: derive the drive from mount paths by finding the common `/mnt/<drive>` prefix
- Best: add to StackDataProvider — clean, explicit
In `layout.html`, update the alert rendering loop to skip inline alerts:
### 1d. StackDataProvider interface update
**Current:**
```html
{{range .Alerts}}
{{if or (not .PageOnly) (pageMatch .PageOnly $.Page)}}
<div class="alert-banner alert-banner-{{.Level}}">
In `internal/backup/appdata.go`, add:
```go
type StackDataProvider interface {
// ... existing methods ...
GetStackHDDPath(name string) string // NEW: raw HDD_PATH from app.yaml
}
```
And implement in the `stackAdapter` in `main.go`.
---
## Phase 2: DB dump refactor
### 2a. `internal/backup/backup.go` — DumpAll()
Currently dumps all DBs to one global directory (`m.cfg.Paths.DBDumpDir`).
**Change to:** For each discovered DB, determine the app's drive, dump to `<drive>/backups/primary/<stack>/db-dumps/`.
Key changes:
- Remove references to `m.cfg.Paths.DBDumpDir`
- Compute dump path per stack: `AppDBDumpPath(m.GetAppDrivePath(stack), stack)`
- Create dir if not exists before dumping
- Update `DumpResult` to include per-stack dump paths
### 2b. `internal/backup/backup.go` — DumpStackDB()
Same refactor for single-stack dump (called by cross-drive before running Tier 2 backup).
### 2c. Status/validation
Currently `RefreshCache()` lists all dump files from one directory. Need to scan per-drive dump directories instead.
- Scan all registered drives (from settings or deployed stacks)
- For each drive, glob `<drive>/backups/primary/*/db-dumps/*.sql`
- Aggregate results
---
## Phase 3: Restic backup refactor
### 3a. `internal/backup/restic.go` — ResticManager
Currently `ResticManager` has a single `repoPath`. Need to support multiple repos.
**Option A:** Make ResticManager stateless — pass repoPath per operation.
**Option B:** Create multiple ResticManager instances.
**Recommend Option A** — cleaner for per-drive operations. Refactor all ResticManager methods to accept `repoPath` as parameter instead of using `r.repoPath`:
- `EnsureInitialized(repoPath string) error`
- `RunBackup(ctx, repoPath string, paths []string, tags []string) (*SnapshotResult, error)`
- `ListSnapshots(repoPath string) ([]SnapshotInfo, error)`
- `GetRepoStats(repoPath string) (*RepoStats, error)`
- `RunCheck(repoPath string) error`
- `RunPrune(repoPath string) error`
- etc.
Keep `r.passwordFile`, `r.cacheDir`, `r.logger` as instance fields.
### 3b. `internal/backup/backup.go` — RunBackup()
Currently:
```go
paths := []string{stacksDir, dbDumpDir, controllerYaml}
paths = append(paths, appPaths...)
// one restic backup
```
**Change to:**
```html
{{range .Alerts}}
{{if and (not .Inline) (or (not .PageOnly) (pageMatch .PageOnly $.Page))}}
<div class="alert-banner alert-banner-{{.Level}}">
```
### Step 4: Add GetInlineAlerts method to AlertManager
In `alerts.go`, add a new method:
```go
// GetInlineAlerts returns alerts marked as Inline for a specific page.
func (am *AlertManager) GetInlineAlerts(page string) []Alert {
am.mu.RLock()
defer am.mu.RUnlock()
func (m *Manager) RunBackup(ctx context.Context) error {
// Group deployed stacks by drive
driveStacks := m.groupStacksByDrive()
var result []Alert
for _, a := range am.alerts {
if !a.Inline {
continue
}
if len(a.PageOnly) == 0 {
result = append(result, a)
continue
}
for _, p := range a.PageOnly {
if p == page {
result = append(result, a)
break
infraPaths := []string{
m.cfg.Paths.StacksDir,
"/opt/docker/felhom-controller/controller.yaml",
}
for drivePath, stacks := range driveStacks {
repoPath := PrimaryResticRepoPath(drivePath)
m.restic.EnsureInitialized(repoPath)
var paths []string
// Always include infra (compose configs + controller.yaml) in every repo
paths = append(paths, infraPaths...)
for _, stack := range stacks {
// App data (appdata/<stack>/)
appData := AppDataPath(drivePath, stack.Name)
if _, err := os.Stat(appData); err == nil {
paths = append(paths, appData)
}
// DB dumps for this stack
dumpDir := AppDBDumpPath(drivePath, stack.Name)
if _, err := os.Stat(dumpDir); err == nil {
paths = append(paths, dumpDir)
}
}
// Tag with drive name for easy filtering
tags := []string{filepath.Base(drivePath)}
m.restic.RunBackup(ctx, repoPath, paths, tags)
}
return result
}
```
### Step 5: Pass inline alerts in dashboard and monitoring handlers
### 3c. Prune, check, forget — per drive
In `handlers.go`:
Currently scheduled as single jobs. Need to loop over all active drive repos:
- `RunPrune()` → for each drive, prune that drive's primary restic repo
- `RunCheck()` → same
- `RunForget()` → same
**In `dashboardHandler`**, after `baseData()` call, add:
### 3d. Snapshot listing & stats — aggregate
For the backup page UI:
- `ListSnapshots()` → list from all primary repos, merge and sort by time
- `GetRepoStats()` → aggregate total size and snapshot count across repos
- Tag snapshots with drive name so UI can optionally group them
### 3e. Monitoring pings
After ALL drive backups complete (not per-drive), send the backup ping. If ANY drive fails, the ping is not sent (or sent as failure).
---
## Phase 4: Cross-drive (secondary) backup refactor
### 4a. `internal/backup/crossdrive.go` — runRsyncBackup()
**Current:** `destDir = filepath.Join(destBase, "backups", "rsync", stackName)`
**New:** `destDir = AppSecondaryRsyncPath(destBase, stackName)``<dest>/backups/secondary/<stack>/rsync/`
Update all path computations:
- `destDir` construction
- `_db/` subdirectory (now under rsync/ too)
- `_config/` subdirectory
- Size calculation path
### 4b. `internal/backup/crossdrive.go` — runResticBackup()
**Current:** `repoPath = filepath.Join(destBase, "backups", "restic")`
**New:** `repoPath = SecondaryResticRepoPath(destBase)``<dest>/backups/secondary/restic/`
### 4c. DB dump source path
Currently: `r.dbDumpDir` (global directory)
Now: per-app dump dir: `AppDBDumpPath(appDrivePath, stackName)`
The cross-drive runner needs to know the app's home drive to find its DB dumps.
- Add `GetAppDrivePath` method to CrossDriveRunner (or pass via StackDataProvider)
---
## Phase 5: Protected paths & delete safety
### 5a. `internal/stacks/delete.go` — ProtectedHDDPaths()
**Current:**
```go
if s.alertManager != nil {
data["DiskWarnings"] = s.alertManager.GetInlineAlerts("dashboard")
return map[string]bool{
hddPath: true,
filepath.Join(hddPath, "media"): true,
filepath.Join(hddPath, "storage"): true,
filepath.Join(hddPath, "Dokumentumok"): true,
filepath.Join(hddPath, "appdata"): true,
}
```
**In `monitoringHandler`**, after `baseData()` call, add:
**Change to:**
```go
if s.alertManager != nil {
data["DiskWarnings"] = s.alertManager.GetInlineAlerts("monitoring")
return map[string]bool{
hddPath: true,
filepath.Join(hddPath, "appdata"): true,
filepath.Join(hddPath, "backups"): true,
filepath.Join(hddPath, "media"): true,
filepath.Join(hddPath, "Dokumentumok"): true,
}
```
### Step 6: Render inline warnings in dashboard.html
Remove `storage` (gone), add `backups`.
In `dashboard.html`, inside the `.system-info-card`, right after the second `.system-info-items` block (the one with SSD and HDD bars) — just before the closing `</div>` of `.system-info-card` — add:
---
```html
{{if .DiskWarnings}}
<div class="inline-warnings">
{{range .DiskWarnings}}
<div class="inline-warning inline-warning-{{.Level}}">
<span class="inline-warning-dot"></span>
<span class="inline-warning-text">{{.Message}}</span>
{{if .Link}}<a href="{{.Link}}" class="inline-warning-link">{{.LinkText}} →</a>{{end}}
</div>
{{end}}
</div>
{{end}}
```
## Phase 6: Filebrowser mount sync
Place this INSIDE the `{{if .SystemInfo.TotalMemMB}}...{{end}}` block, after the storage bars but before the closing `</div>` of `.system-info-card`.
### 6a. `internal/web/handlers.go` — syncFileBrowserMounts()
### Step 7: Render inline warnings in monitoring.html
**Current:** Mounts each registered path as one volume: `<path>:/srv/<basename>`
This exposes EVERYTHING on the drive, including encrypted restic repos and raw appdata.
In `monitoring.html`, inside the Tárhely `.monitor-card` (currently Section 1.5), right after the `.storage-bars` closing `</div>` but still inside the `.monitor-card`, add the same block:
**Change to:** Mount specific subdirectories per drive:
```go
for _, sp := range paths {
driveName := filepath.Base(sp.Path) // "hdd_1", "sys_drive"
```html
{{if .DiskWarnings}}
<div class="inline-warnings">
{{range .DiskWarnings}}
<div class="inline-warning inline-warning-{{.Level}}">
<span class="inline-warning-dot"></span>
<span class="inline-warning-text">{{.Message}}</span>
{{if .Link}}<a href="{{.Link}}" class="inline-warning-link">{{.LinkText}} →</a>{{end}}
</div>
{{end}}
</div>
{{end}}
```
// User media
mediaPath := filepath.Join(sp.Path, "media")
if dirExists(mediaPath) {
storageMounts = append(storageMounts,
fmt.Sprintf(" - %s:/srv/%s/media", mediaPath, driveName))
}
### Step 8: Add CSS for inline warnings
// User documents
docsPath := filepath.Join(sp.Path, "Dokumentumok")
if dirExists(docsPath) {
storageMounts = append(storageMounts,
fmt.Sprintf(" - %s:/srv/%s/Dokumentumok", docsPath, driveName))
}
In `style.css`, add:
```css
.inline-warnings {
margin-top: 0.75rem;
}
.inline-warning {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
margin-top: 0.25rem;
font-size: 0.8rem;
border-radius: 6px;
}
.inline-warning-warning {
color: var(--yellow);
background: rgba(250, 204, 21, 0.06);
border: 1px solid rgba(250, 204, 21, 0.15);
}
.inline-warning-error {
color: var(--red);
background: rgba(218, 54, 51, 0.06);
border: 1px solid rgba(218, 54, 51, 0.15);
}
.inline-warning-dot {
font-size: 0.6rem;
flex-shrink: 0;
}
.inline-warning-text {
flex: 1;
}
.inline-warning-link {
color: inherit;
opacity: 0.8;
white-space: nowrap;
text-decoration: none;
}
.inline-warning-link:hover {
opacity: 1;
text-decoration: underline;
// Secondary backup copies (rsync — browseable for file recovery)
secPath := filepath.Join(sp.Path, "backups", "secondary")
if dirExists(secPath) {
storageMounts = append(storageMounts,
fmt.Sprintf(" - %s:/srv/%s/backups:ro", secPath, driveName))
}
}
```
This makes the warnings compact (small font, subtle background, thin border) and positioned right under the storage bar they relate to.
This gives Filebrowser users access to:
- Their media files (movies, music, etc.)
- Their documents
- Secondary backup copies (rsync) for file recovery
- NOT raw appdata (dangerous), NOT restic repos (useless)
---
## Phase 7: App catalog changes
### 7a. Compose file updates (`app-catalog-felhom.eu`)
All 11+ apps with `needs_hdd: true`: rename `${HDD_PATH}/storage/``${HDD_PATH}/appdata/` in volume mounts.
**Apps to update** (grep for `storage/` in compose files):
- immich, paperless-ngx, audiobookshelf, calibre-web, emby, jellyfin, komga, navidrome, nextcloud, plex, radarr, romm, sonarr
Each compose file's volumes section changes, e.g.:
```yaml
# Before:
- ${HDD_PATH}/storage/immich:/usr/src/app/upload
# After:
- ${HDD_PATH}/appdata/immich:/usr/src/app/upload
```
### 7b. Media-centric apps (Jellyfin, Plex, Emby, Radarr, Sonarr)
These apps also mount media directories. Check if they reference `${HDD_PATH}/media/` — if so, that's correct (no rename needed for media/).
### 7c. `.felhom.yml` files
The `HDD_PATH` field metadata doesn't reference `storage/` — it just declares the env var. Description says "külső merevlemez elérési útja" which is fine. No changes needed.
---
## Phase 8: Setup script updates
### 8a. `scripts/docker-setup.sh`
- Update `install_filebrowser()` volume mounts to use new per-subdirectory pattern
- Or: remove Filebrowser initial mounts entirely (controller will sync them on startup)
### 8b. `scripts/hdd-setup.sh`
- Update `STORAGE_DIRS` to remove `storage/` entries
- Update to use `appdata/` naming
- Or: mark as deprecated (controller handles disk init now)
---
## Phase 9: controller.yaml update
New controller.yaml for demo node (after OS reinstall with SSD partition):
```yaml
paths:
stacks_dir: "/opt/docker/stacks"
system_data_path: "/mnt/sys_drive"
backup:
enabled: true
restic_password_file: "/opt/docker/felhom-controller/data/restic-password"
db_dump_schedule: "02:30"
restic_schedule: "03:00"
retention:
keep_daily: 7
keep_weekly: 4
keep_monthly: 6
prune_schedule: "sunday"
```
No more `restic_repo`, `db_dump_dir`, `backup_dir`.
---
## Phase 10: UI — Tároló section (simple update)
The Tároló section on the backup page needs to work with the new multi-drive, multi-repo architecture. Since we already agreed to show combined stats (not paths):
- **Tier 1 summary:** Aggregate snapshot count + total size across all primary repos
- **Tier 2 summary:** How many apps configured, total size
- **Keep:** Encryption key display (same password for all repos)
- **Remove:** Path displays, DB dump section (unnecessary detail)
---
## Gotchas & risks
1. **Grep for ALL references** to removed config fields: `BackupDir`, `DBDumpDir`, `ResticRepo`, `cfg.Backup.ResticRepo`, `cfg.Paths.DBDumpDir`, `cfg.Paths.BackupDir`
2. **ResticManager refactor** changes all call sites — grep for `m.restic.` in backup.go
3. **DB dump path in crossdrive.go** — currently `r.dbDumpDir` (global). Needs per-app resolution.
4. **Snapshot aggregation** — merging snapshots from multiple repos for the UI. Need to handle different repo sizes, dedup by timestamp.
5. **New restic repo initialization** — when a new drive is registered and first backup runs, `restic init` must succeed before `restic backup`. The `EnsureInitialized` pattern already exists.
6. **Empty drives** — a drive with no apps deployed yet should NOT get a restic backup (empty paths). Skip drives with zero apps.
7. **The `systemDataPath` as fallback** — SSD-only apps (Mealie, Gokapi) have no HDD_PATH. Their drive is `cfg.Paths.SystemDataPath`. Make sure this path exists and is registered as a storage path.
8. **Compose config files in multiple repos**`/opt/docker/stacks/` is included in every drive's primary repo. This means the same files are in multiple repos. That's intentional (each repo is self-contained) but uses slightly more storage.
9. **The `ParseComposeHDDMounts` function** references `${HDD_PATH}` with `storage/` subdirs. After rename to `appdata/`, the compose files change, so the parsed mounts change too. The function itself is generic (parses any `${HDD_PATH}` prefix) so it doesn't need code changes — only the compose templates change.
10. **docker-compose.yml volumes in felhom-controller** — currently `- /srv/backups:/srv/backups`. This mount becomes unnecessary since all backups are under `/mnt/`. The `/mnt:/mnt:rshared` mount already provides access. Can remove the `/srv/backups` volume mount from the controller's compose file.
---
## Implementation order
1. **Fix 3**Pillanatképek 0 vs n/a (template-only, 1 min)
2. **Fix 1**Deploy page card border (CSS only, 1 min)
3. **Fix 2**Auto-generated values cleanup (template + CSS)
4. **Fix 4** — Disk warnings inline (alerts.go + layout.html + dashboard.html + monitoring.html + handlers.go + style.css)
1. **Phase 1**Config + path helpers + StackDataProvider update (foundation, everything depends on this)
2. **Phase 7**App catalog compose files (independent, can do in parallel)
3. **Phase 5**Protected paths (quick, independent)
4. **Phase 2** — DB dump refactor
5. **Phase 3** — Restic backup refactor (depends on Phase 1 + 2)
6. **Phase 4** — Cross-drive backup refactor (depends on Phase 1)
7. **Phase 6** — Filebrowser mount sync
8. **Phase 10** — UI Tároló section
9. **Phase 8** — Setup scripts
10. **Phase 9** — controller.yaml
## Build & deploy
Build, deploy to reinstalled demo node, verify.
After all fixes, bump version to **v0.13.1**, then follow the standard build workflow in `CLAUDE.md`:
---
```
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 (read top 30 lines only for format, then Edit to insert)
3. Commit, build, and deploy following the workflow in CLAUDE.md
```
## Files to modify
### Controller (deploy-felhom-compose/controller/)
| File | Phase | Changes |
|------|-------|---------|
| `internal/config/config.go` | 1a | Add SystemDataPath, remove BackupDir/DBDumpDir/ResticRepo |
| `internal/backup/paths.go` | 1b | **NEW FILE** — path computation helpers |
| `internal/backup/appdata.go` | 1d | Add GetStackHDDPath to StackDataProvider |
| `cmd/controller/main.go` | 1d | Implement GetStackHDDPath in stackAdapter |
| `internal/backup/backup.go` | 2+3 | DumpAll, DumpStackDB, RunBackup, RefreshCache, GetFullStatus, RunPrune, RunCheck |
| `internal/backup/restic.go` | 3a | Make repoPath a parameter, not instance field |
| `internal/backup/crossdrive.go` | 4 | Update destination paths, DB dump source paths |
| `internal/stacks/delete.go` | 5 | Update ProtectedHDDPaths |
| `internal/web/handlers.go` | 6+10 | syncFileBrowserMounts, backupsHandler |
| `internal/web/templates/backups.html` | 10 | Tároló section |
### App catalog (app-catalog-felhom.eu/)
| File | Phase | Changes |
|------|-------|---------|
| `templates/*/docker-compose.yml` (11+ files) | 7 | `storage/``appdata/` in volume mounts |
### Scripts
| File | Phase | Changes |
|------|-------|---------|
| `scripts/docker-setup.sh` | 8 | Filebrowser mounts, path references |
| `scripts/hdd-setup.sh` | 8 | Directory structure arrays |
### Config
| File | Phase | Changes |
|------|-------|---------|
| Demo node `controller.yaml` | 9 | New paths config |
| Demo node `docker-compose.yml` | 10 | Remove `/srv/backups` mount |