v0.11.9 UI Polish Fixes

This commit is contained in:
2026-02-17 16:06:16 +01:00
parent 1a8036d055
commit a56448f7c9
+322 -554
View File
@@ -1,624 +1,392 @@
# Per-App Cross-Drive Backup — Design & Task Document
# TASK — v0.11.9 UI Polish Fixes
## Overview
## Context
Extend the controller with per-app user data backup to a **secondary storage drive**. This is distinct from the existing nightly restic snapshot (which backs up to the same drive). The cross-drive backup provides the "second copy on different media" part of the 3-2-1 backup rule.
These are UI-only fixes for the deploy/settings page backup section introduced in v0.11.8. No backend changes needed. All changes are in `deploy.html` and `style.css`.
Two mechanisms available (user chooses per app):
- **rsync** — Simple file mirror. Easy to browse via FileBrowser. No versioning.
- **restic** — Versioned, encrypted, deduplicated snapshots on the secondary drive.
**Important design principle:** Use minimal emojis in the UI. The project prefers a clean, professional look. Replace emoji indicators with text or CSS-styled elements instead.
### Current State (what already exists)
## Files to modify
| Feature | Status | Location |
|---------|--------|----------|
| Per-app backup toggle | ✅ Exists | Backup page, `settings.json` `app_backup` map |
| `resolveAppBackupPaths()` | ✅ Exists | `backup.go` — includes enabled app HDD paths in nightly restic |
| `AppBackupInfo` discovery | ✅ Exists | `appdata.go` — discovers HDD mounts, Docker volumes per app |
| Storage paths registry | ✅ Exists | `settings.json` — multiple paths with labels, health, default |
| Mount health checks | ✅ Exists | `mounts_linux.go``IsMountPoint()`, `GetDiskUsage()` |
| Scheduler | ✅ Exists | `scheduler.go` — daily cron-style jobs |
| Stale data cleanup | ✅ v0.11.7 | Deploy page — delete old data from non-active paths |
### What's New
The existing backup toggle (`Enabled bool`) includes app data in the **same-drive** restic snapshot. The new feature adds a completely separate **cross-drive** backup job with its own method, destination, and schedule.
- `internal/web/templates/deploy.html`
- `internal/web/templates/style.css`
- `internal/web/templates/backups.html` (emoji cleanup in cross-drive summary section)
---
## Data Model
## Fix 1: Spacing between cards and "Automatikusan generált értékek"
### Extended `AppBackupPrefs` in `settings.json`
**Problem:** No clear visual separation between the last card above the deploy form (`deploy-cross-drive` or `deploy-stale-data`) and the "Automatikusan generált értékek" section inside `.deploy-form`.
```go
// AppBackupPrefs holds per-app backup configuration.
type AppBackupPrefs struct {
// Existing: includes app data in nightly restic (same drive)
Enabled bool `json:"enabled"`
**Root cause:** `.deploy-cross-drive` has `margin-bottom: 1rem` which doesn't provide enough separation before the next card. When stale data card exists without cross-drive, it's also tight.
// NEW: Cross-drive backup to secondary storage
CrossDrive *CrossDriveBackup `json:"cross_drive,omitempty"`
}
**Fix in `style.css`:**
// CrossDriveBackup configures per-app backup to a secondary drive.
type CrossDriveBackup struct {
Enabled bool `json:"enabled"`
Method string `json:"method"` // "rsync" or "restic"
DestinationPath string `json:"destination_path"` // e.g., "/mnt/hdd_1"
Schedule string `json:"schedule"` // "daily", "weekly", "manual"
// Runtime state (updated by backup runner, persisted for display)
LastRun string `json:"last_run,omitempty"` // RFC3339
LastStatus string `json:"last_status,omitempty"` // "ok", "error", "running"
LastError string `json:"last_error,omitempty"`
LastDuration string `json:"last_duration,omitempty"` // "2m34s"
LastSizeHuman string `json:"last_size_human,omitempty"` // "1.2 GB"
```css
/* Change margin-bottom from 1rem to 1.5rem */
.deploy-cross-drive {
/* ... existing ... */
margin-bottom: 1.5rem; /* was 1rem */
}
```
Example `settings.json`:
```json
{
"app_backup": {
"immich": {
"enabled": true,
"cross_drive": {
"enabled": true,
"method": "rsync",
"destination_path": "/mnt/hdd_1",
"schedule": "daily",
"last_run": "2026-02-17T03:15:00Z",
"last_status": "ok",
"last_duration": "45s",
"last_size_human": "48 MB"
}
},
"paperless-ngx": {
"enabled": true,
"cross_drive": {
"enabled": true,
"method": "restic",
"destination_path": "/mnt/hdd_1",
"schedule": "weekly"
}
}
}
}
```
### Cross-drive backup directory layout
On the destination drive:
```
/mnt/hdd_1/
├── storage/ # App user data (active apps store data here)
│ ├── immich/
│ └── paperless-ngx/
├── media/ # User media files
├── Dokumentumok/ # User documents
└── backups/ # NEW: Cross-drive backups
├── rsync/ # Mirror copies
│ ├── immich/ # rsync of /mnt/hdd_placeholder/storage/immich/
│ └── ...
└── restic/ # Restic repository for versioned backups
├── config
├── data/
├── index/
├── keys/
└── snapshots/
```
Key decisions:
- All cross-drive backups go under `{destination}/backups/` to keep them separate from active app data
- rsync method: one directory per app under `backups/rsync/{stackname}/`
- restic method: single shared restic repo at `backups/restic/` (dedup benefits from shared repo)
- Restic repo on secondary drive uses a **separate password** stored in `settings.json` (not the same as the main backup repo)
No change needed for `.deploy-stale-data` — it already has `margin-bottom: 1.5rem`. But verify the margin is actually applied (check if another element is overriding it or if the stale data card is inside a container that collapses margins).
---
## Architecture
## Fix 2: Rename restic method + add info tooltip on "Módszer"
### New package: `internal/backup/crossdrive.go`
**Problem:** "Verziózott mentés (restic)" doesn't highlight the most important differentiator (encryption). Users should also understand the tradeoffs before picking.
```go
// CrossDriveRunner handles per-app backup to secondary storage.
type CrossDriveRunner struct {
settings *settings.Settings
stackProvider StackDataProvider
logger *log.Logger
mu sync.Mutex
running map[string]bool // per-app running state
**Fix in `deploy.html` — method dropdown (around line 16934-16942):**
Replace:
```html
<div class="settings-row">
<span class="settings-label">Módszer</span>
<select name="cross_drive_method" class="form-control" style="max-width:20rem">
<option value="rsync" {{if and .CrossDriveConfig (eq .CrossDriveConfig.Method "rsync")}}selected{{end}}>
Egyszerű másolat (rsync)
</option>
<option value="restic" {{if and .CrossDriveConfig (eq .CrossDriveConfig.Method "restic")}}selected{{end}}>
Verziózott mentés (restic)
</option>
</select>
</div>
```
With:
```html
<div class="settings-row">
<span class="settings-label">
Módszer
<span class="info-tooltip" tabindex="0">
<span class="info-icon">i</span>
<span class="info-tooltip-text">
<strong>Egyszerű másolat (rsync):</strong> Tükörszerű másolat, a fájlok közvetlenül böngészhetők.
Nem titkosított, nem verziózott — mindig a legfrissebb állapotot tartalmazza.
<br><br>
<strong>Titkosított mentés (restic):</strong> Titkosított, tömörített, verziózott mentés.
Korábbi állapotok visszaállíthatók. Nem böngészhető közvetlenül —
visszaállításhoz a vezérlőpult szükséges.
</span>
</span>
</span>
<select name="cross_drive_method" class="form-control" style="max-width:20rem">
<option value="rsync" {{if and .CrossDriveConfig (eq .CrossDriveConfig.Method "rsync")}}selected{{end}}>
Egyszerű másolat (rsync)
</option>
<option value="restic" {{if and .CrossDriveConfig (eq .CrossDriveConfig.Method "restic")}}selected{{end}}>
Titkosított mentés (restic)
</option>
</select>
</div>
```
**Add to `style.css`:**
```css
/* Info tooltip (i icon with hover popup) */
.info-tooltip {
position: relative;
display: inline-flex;
align-items: center;
margin-left: .35rem;
cursor: help;
}
// RunAppBackup runs cross-drive backup for a single app.
func (r *CrossDriveRunner) RunAppBackup(ctx context.Context, stackName string) error
.info-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
border: 1px solid var(--text-muted);
color: var(--text-muted);
font-size: .65rem;
font-weight: 700;
font-style: italic;
font-family: Georgia, serif;
line-height: 1;
}
// RunAllScheduled runs cross-drive backup for all apps matching the schedule.
func (r *CrossDriveRunner) RunAllScheduled(ctx context.Context, schedule string) error
.info-tooltip-text {
display: none;
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
width: 320px;
padding: .75rem 1rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
font-size: .8rem;
font-weight: 400;
line-height: 1.5;
color: var(--text-secondary);
z-index: 100;
white-space: normal;
}
// GetAppStatus returns the current cross-drive backup status for an app.
func (r *CrossDriveRunner) GetAppStatus(stackName string) *CrossDriveStatus
/* Show on hover or focus (for keyboard) */
.info-tooltip:hover .info-tooltip-text,
.info-tooltip:focus .info-tooltip-text,
.info-tooltip:focus-within .info-tooltip-text {
display: block;
}
/* Arrow pointing down from tooltip */
.info-tooltip-text::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: var(--border-color);
}
```
### rsync backup flow
```
1. Validate: destination path mounted & writable
2. Resolve app HDD mounts (e.g., /mnt/hdd_placeholder/storage/immich/)
3. Create destination: {dest}/backups/rsync/{stackname}/
4. For each HDD mount:
rsync -a --delete --info=progress2 \
/mnt/hdd_placeholder/storage/immich/ \
/mnt/hdd_1/backups/rsync/immich/storage/immich/
5. Update settings: last_run, last_status, last_size_human
```
Note: `--delete` mirrors exactly — old files on destination get removed. This is a mirror, not versioned.
### restic backup flow
```
1. Validate: destination path mounted & writable
2. Ensure shared restic repo initialized at {dest}/backups/restic/
3. Resolve app HDD mounts
4. restic backup --repo {dest}/backups/restic/ \
--password-file {settings-based} \
--tag {stackname} \
/mnt/hdd_placeholder/storage/immich/
5. Update settings: last_run, last_status, last_size_human
```
Restic benefits: dedup across apps, versioned snapshots, can restore specific point-in-time.
---
## UI Design
## Fix 3: Cursor on "Napi mentésbe foglalás" label
### 1. Deploy/Settings Page — Per-App Section
**Problem:** The `<label class="toggle">` wrapping the disabled checkbox uses `cursor: pointer` from the `.toggle` class, making it look clickable when it's not.
On the deploy page (when `AlreadyDeployed`), after the "Adattárolás" card and before/after the "Korábbi adatok" card, add a new **"Biztonsági mentés"** card:
**Fix in `deploy.html` (around line 16886-16890):**
```
┌────────────────────────────────────────────────────────┐
│ 🔒 Biztonsági mentés │
│ │
│ ☑ Napi mentésbe foglalás (restic, helyi) │
│ Az alkalmazás adatai bekerülnek az éjszakai │
│ biztonsági mentésbe. │
│ │
│ ───────────────────────────────────────────── │
│ │
│ Másolat másik meghajtóra: │
│ │
│ Cél tárhely: [▼ Külső HDD 1TB (/mnt/hdd_1) ★] │
│ Módszer: [▼ Egyszerű másolat (rsync) ] │
│ Ütemezés: [▼ Naponta ] │
│ │
│ Utolsó futás: 2026-02-17 03:15 — ✅ Sikeres (45s) │
│ Méret: 48 MB │
│ │
│ [Mentés most] [Beállítások mentése] │
│ │
│ ⚠️ A cél meghajtó legyen más fizikai eszköz, mint │
│ az alkalmazás adattárolója. │
└────────────────────────────────────────────────────────┘
Replace:
```html
<div class="cross-drive-nightly">
<label class="toggle">
<input type="checkbox" id="app-backup-enabled" {{if .AppBackupEnabled}}checked{{end}} disabled>
<span class="toggle-label">Napi mentésbe foglalás (restic, helyi)</span>
</label>
```
**States:**
- **No other storage path available:** Card visible but form disabled with message: "Másik adattároló szükséges a másolat készítéséhez. Csatlakoztass egy külső meghajtót a Beállítások oldalon."
- **Other path available but not configured:** Dropdowns shown, save button active
- **Configured and healthy:** Shows last run status, manual trigger available
- **Configured but destination unreachable:** Red warning: "⚠️ A cél tárhely ({path}) nem elérhető! Ellenőrizd a meghajtó csatlakozását."
### 2. Backup Page — Summary Card
On the central "Biztonsági mentés" page, add a new section after "Alkalmazás adatok":
```
┌────────────────────────────────────────────────────────┐
│ Másolatok másik meghajtóra │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Immich rsync → hdd_1 ✅ 03:15 48MB │ │
│ │ Paperless-ngx restic → hdd_1 ⏰ Heti (V) │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ ⚠️ 1 alkalmazáshoz nincs beállítva: │
│ RoMM — Beállítás → │
│ │
│ [Összes futtatása most] │
└────────────────────────────────────────────────────────┘
With:
```html
<div class="cross-drive-nightly">
<div class="cross-drive-nightly-status">
{{if .AppBackupEnabled}}
<span class="nightly-status-indicator nightly-enabled"></span>
{{else}}
<span class="nightly-status-indicator nightly-disabled"></span>
{{end}}
<span class="toggle-label">Napi mentésbe foglalás (restic, helyi)</span>
</div>
```
Each row links to the app's deploy/settings page. Shows warnings for:
- Apps with HDD data but no cross-drive backup configured
- Destinations that are unreachable/unmounted
- Last run failures
This replaces the disabled checkbox with a simple colored dot indicator — green if enabled, gray if not. Not clickable, not a checkbox, no confusing cursor.
**Add to `style.css`:**
```css
/* Nightly backup status indicator (non-interactive) */
.cross-drive-nightly-status {
display: flex;
align-items: center;
gap: .5rem;
}
.nightly-status-indicator {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.nightly-enabled {
background: var(--green);
box-shadow: 0 0 4px rgba(35, 134, 54, 0.4);
}
.nightly-disabled {
background: var(--text-muted);
opacity: 0.5;
}
```
---
## Routes
## Fix 4: Progressive disclosure — disable config fields until enabled
### New API endpoints
**Problem:** Users see the destination/method/schedule dropdowns and might think the backup is already configured/active, even though the "Engedélyezve" checkbox is unchecked.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/stacks/{name}/cross-backup` | Yes | Save cross-drive backup config for app |
| POST | `/api/stacks/{name}/cross-backup/run` | Yes | Trigger manual run for single app |
| GET | `/api/stacks/{name}/cross-backup/status` | Yes | Get current status (for polling) |
| POST | `/api/backup/cross-drive/run-all` | Yes | Trigger all scheduled cross-drive backups |
**Fix in `deploy.html` — add JS to toggle field states:**
### New web handler
| Method | Path | Description |
|--------|------|-------------|
| POST | `/settings/cross-backup/{name}` | Form POST from deploy page (redirect back) |
---
## Implementation Steps
### Step 0: CSS fix (immediate)
Add `margin-bottom: 1.5rem` to `.deploy-stale-data` in `style.css`.
### Step 1: Extend data model
**Files:** `settings.go`
- Add `CrossDriveBackup` struct
- Extend `AppBackupPrefs` with `CrossDrive` field
- Add getter/setter methods:
- `GetCrossDriveConfig(stackName string) *CrossDriveBackup`
- `SetCrossDriveConfig(stackName string, cfg CrossDriveBackup) error`
- `GetAllCrossDriveConfigs() map[string]*CrossDriveBackup`
- Add `CrossDriveResticPassword` field to Settings for the secondary restic repo
- Auto-generate password on first restic cross-drive config save
### Step 2: Cross-drive backup runner
**New file:** `internal/backup/crossdrive.go`
- `CrossDriveRunner` struct with mutex, settings, stack provider, logger
- `RunAppBackup(ctx, stackName)`:
1. Load config from settings
2. Validate destination: `IsMountPoint()` + `IsWritable()`
3. Resolve HDD mounts via `StackDataProvider`
4. Branch on method:
- rsync: `runRsyncBackup()` — runs rsync per mount with `--delete --info=progress2`
- restic: `runResticBackup()` — ensures repo init, runs `restic backup` with app tag
5. Update settings with result (last_run, last_status, etc.)
6. Log result
- `RunAllScheduled(ctx, schedule)`:
1. Iterate all apps with cross_drive enabled
2. Filter by schedule match (daily → every day, weekly → Sunday)
3. Run sequentially (not parallel — disk I/O bound)
- `GetAppStatus(stackName)` — returns latest status from settings
- `ValidateDestination(path)` — checks mount + writable + free space
**rsync specifics:**
```go
func (r *CrossDriveRunner) runRsyncBackup(stackName, destBase string, mounts []string) error {
destDir := filepath.Join(destBase, "backups", "rsync", stackName)
os.MkdirAll(destDir, 0755)
for _, srcMount := range mounts {
// Preserve directory structure relative to storage root
// e.g., /mnt/hdd_placeholder/storage/immich/ → {dest}/backups/rsync/immich/storage/immich/
relPath := strings.TrimPrefix(srcMount, filepath.Dir(filepath.Dir(srcMount)))
dstPath := filepath.Join(destDir, relPath)
os.MkdirAll(filepath.Dir(dstPath), 0755)
cmd := exec.Command("rsync", "-a", "--delete",
srcMount+"/", dstPath+"/")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("rsync failed for %s: %v (%s)", srcMount, err, string(out))
}
}
return nil
}
```
**restic specifics:**
```go
func (r *CrossDriveRunner) runResticBackup(stackName, destBase string, mounts []string) error {
repoPath := filepath.Join(destBase, "backups", "restic")
passwordFile := r.getResticPasswordFile() // from settings or dedicated file
// Ensure initialized
if !r.isRepoInitialized(repoPath) {
cmd := exec.Command("restic", "init", "--repo", repoPath, "--password-file", passwordFile)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("restic init failed: %v (%s)", err, string(out))
}
}
// Build args
args := []string{"backup", "--repo", repoPath, "--password-file", passwordFile,
"--tag", stackName, "--tag", "cross-drive"}
args = append(args, mounts...)
cmd := exec.Command("restic", args...)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("restic backup failed: %v (%s)", err, string(out))
}
return nil
}
```
### Step 3: Scheduler integration
**File:** `main.go` (scheduler registration)
Add a new daily job that runs after the existing backup:
```go
// Cross-drive backup job — runs at 03:30 (after main backup at 03:00)
sched.RegisterDaily("cross_drive_backup", "03:30", func(ctx context.Context) error {
return crossDriveRunner.RunAllScheduled(ctx, "daily")
})
// Weekly cross-drive job — runs Sundays at 04:00
sched.RegisterWeekly("cross_drive_weekly", time.Sunday, "04:00", func(ctx context.Context) error {
return crossDriveRunner.RunAllScheduled(ctx, "weekly")
})
```
Note: If `RegisterWeekly` doesn't exist in the scheduler, we can check the day inside `RunAllScheduled` (like the existing `shouldPrune` pattern).
### Step 4: API endpoints
**File:** `internal/api/router.go`
Add to the switch:
```go
// POST /api/stacks/{name}/cross-backup — save config
case hasSuffix(path, "/cross-backup") && req.Method == http.MethodPost:
r.saveCrossBackupConfig(w, req, extractName(path, "/cross-backup"))
// POST /api/stacks/{name}/cross-backup/run — trigger manual run
case hasSuffix(path, "/cross-backup/run") && req.Method == http.MethodPost:
r.triggerCrossBackup(w, req, extractNameFromPath(path, "/cross-backup/run"))
// GET /api/stacks/{name}/cross-backup/status — poll status
case hasSuffix(path, "/cross-backup/status") && req.Method == http.MethodGet:
r.getCrossBackupStatus(w, req, extractNameFromPath(path, "/cross-backup/status"))
// POST /api/backup/cross-drive/run-all — trigger all
case path == "/backup/cross-drive/run-all" && req.Method == http.MethodPost:
r.triggerAllCrossBackups(w, req)
```
### Step 5: Deploy page UI
**File:** `internal/web/templates/deploy.html`
After the StorageInfo section, add the backup card for deployed apps with HDD data. The card is rendered server-side with current config values.
**File:** `internal/web/handlers.go`
In `deployHandler`, populate new template data:
```go
if alreadyDeployed {
// ... existing storageInfo ...
// Cross-drive backup config for this app
crossCfg := s.settings.GetCrossDriveConfig(name)
data["CrossDriveConfig"] = crossCfg
// Other storage paths for destination dropdown (exclude current app path)
var destPaths []DeployStoragePath
for _, sp := range s.settings.GetStoragePaths() {
if storageInfo != nil && sp.Path == storageInfo.Path {
continue // skip the app's current storage
}
dp := DeployStoragePath{StoragePath: sp}
if di := system.GetDiskUsage(sp.Path); di != nil {
dp.FreeHuman = formatFreeSpace(di.AvailGB)
dp.FreePercent = di.AvailGB / di.TotalGB * 100
}
destPaths = append(destPaths, dp)
}
data["BackupDestPaths"] = destPaths
// Destination health warning
if crossCfg != nil && crossCfg.Enabled && crossCfg.DestinationPath != "" {
if !system.IsMountPoint(crossCfg.DestinationPath) || !system.IsWritable(crossCfg.DestinationPath) {
data["BackupDestWarning"] = fmt.Sprintf(
"A cél tárhely (%s) nem elérhető! Ellenőrizd a meghajtó csatlakozását.",
crossCfg.DestinationPath,
)
}
}
// Existing nightly backup toggle state
appBackupEnabled := false
if prefs, ok := s.settings.GetAppBackupPrefs(name); ok {
appBackupEnabled = prefs.Enabled
}
data["AppBackupEnabled"] = appBackupEnabled
}
```
### Step 6: Backup page summary
**File:** `internal/web/templates/backups.html`
Add section after "Alkalmazás adatok":
Add `onchange` handler to the enabled checkbox and `disabled` attribute to the select fields:
```html
<!-- Section 5: Cross-drive backups -->
{{if .Backup.CrossDriveSummary}}
<div class="backup-section-card">
<h3>Másolatok másik meghajtóra</h3>
<p class="backup-section-desc">Alkalmazás adatok biztonsági másolata külső meghajtóra.</p>
{{if .Backup.CrossDriveWarnings}}
<div class="alert alert-warning" style="margin-bottom:1rem">
{{range .Backup.CrossDriveWarnings}}
<div>{{.}}</div>
{{end}}
</div>
{{end}}
<div class="cross-drive-list">
{{range .Backup.CrossDriveSummary}}
<div class="cross-drive-item">
<div class="cross-drive-header">
<a href="/stacks/{{.StackName}}/deploy" class="cross-drive-name">{{.DisplayName}}</a>
<div class="cross-drive-meta">
<span class="meta-badge">{{.MethodLabel}}</span>
<span class="meta-badge meta-badge-storage">→ {{.DestLabel}}</span>
{{if eq .LastStatus "ok"}}<span class="meta-badge meta-badge-ok">✅ {{.LastRunShort}}</span>
{{else if eq .LastStatus "error"}}<span class="meta-badge meta-badge-fail">❌ Hiba</span>
{{else}}<span class="meta-badge">⏰ {{.ScheduleLabel}}</span>{{end}}
{{if .SizeHuman}}<span class="mono" style="font-size:.8rem;color:var(--text-muted)">{{.SizeHuman}}</span>{{end}}
</div>
</div>
</div>
{{end}}
</div>
{{if .Backup.UnconfiguredApps}}
<div style="margin-top:1rem;font-size:.85rem;color:var(--yellow)">
⚠️ {{len .Backup.UnconfiguredApps}} alkalmazáshoz nincs beállítva:
{{range .Backup.UnconfiguredApps}}
<a href="/stacks/{{.StackName}}/deploy" style="color:var(--accent-blue)">{{.DisplayName}}</a>{{if not $.Last}}, {{end}}
{{end}}
</div>
{{end}}
<div class="cross-drive-actions" style="margin-top:1rem">
<button class="btn btn-sm btn-primary" onclick="triggerAllCrossDrive()">Összes futtatása most</button>
</div>
<div class="settings-row">
<span class="settings-label">Engedélyezve</span>
<label class="toggle" style="margin:0">
<input type="checkbox" name="cross_drive_enabled" id="cross-drive-enabled"
{{if and .CrossDriveConfig .CrossDriveConfig.Enabled}}checked{{end}}
onchange="toggleCrossDriveFields()">
<span class="toggle-label">Igen</span>
</label>
</div>
<div class="settings-row">
<span class="settings-label">Cél tárhely</span>
<select name="cross_drive_dest" id="cd-dest" class="form-control cross-drive-field" style="max-width:20rem"
{{if not (and .CrossDriveConfig .CrossDriveConfig.Enabled)}}disabled{{end}}>
...options...
</select>
</div>
<div class="settings-row">
<span class="settings-label">
Módszer
<span class="info-tooltip" tabindex="0">...</span>
</span>
<select name="cross_drive_method" id="cd-method" class="form-control cross-drive-field" style="max-width:20rem"
{{if not (and .CrossDriveConfig .CrossDriveConfig.Enabled)}}disabled{{end}}>
...options...
</select>
</div>
<div class="settings-row">
<span class="settings-label">Ütemezés</span>
<select name="cross_drive_schedule" id="cd-schedule" class="form-control cross-drive-field" style="max-width:20rem"
{{if not (and .CrossDriveConfig .CrossDriveConfig.Enabled)}}disabled{{end}}>
...options...
</select>
</div>
{{end}}
```
**File:** `internal/web/handlers.go` — in backup page handler
Add JS function (inside the existing `<script>` block at the bottom of deploy.html):
Populate the summary data:
```go
// Cross-drive backup summary
type CrossDriveSummaryItem struct {
StackName string
DisplayName string
Method string // "rsync" or "restic"
MethodLabel string // "Egyszerű másolat" or "Restic"
DestPath string
DestLabel string
Schedule string
ScheduleLabel string // "Naponta" or "Hetente"
LastStatus string
LastRunShort string
SizeHuman string
```javascript
function toggleCrossDriveFields() {
var enabled = document.getElementById('cross-drive-enabled').checked;
var fields = document.querySelectorAll('.cross-drive-field');
for (var i = 0; i < fields.length; i++) {
fields[i].disabled = !enabled;
}
}
```
### Step 7: Destination health monitoring
**Important:** The `disabled` attribute prevents form submission of those fields. The backend handler for `POST /settings/cross-backup/{name}` must handle missing form values gracefully — if `cross_drive_enabled` is unchecked (not in form data), set `Enabled: false` and preserve existing dest/method/schedule values from settings (don't reset them to empty strings just because they weren't submitted).
**File:** `internal/web/handlers.go` or `internal/monitor/health.go`
Check `internal/web/handlers.go` (or `internal/api/router.go`) — the `saveCrossBackupConfig` handler. If it reads form values like:
```go
cfg.DestinationPath = req.FormValue("cross_drive_dest")
cfg.Method = req.FormValue("cross_drive_method")
```
In the periodic health check (runs every 5 min), also check cross-drive destinations:
Then when disabled fields aren't submitted, these would be empty strings. Fix: only update those fields if enabled is true, otherwise preserve existing config:
```go
func (s *Server) checkCrossDriveDestinations() []string {
var warnings []string
configs := s.settings.GetAllCrossDriveConfigs()
seen := make(map[string]bool)
enabled := req.FormValue("cross_drive_enabled") == "on"
for stackName, cfg := range configs {
if !cfg.Enabled || cfg.DestinationPath == "" || seen[cfg.DestinationPath] {
continue
}
seen[cfg.DestinationPath] = true
// Load existing config to preserve values when disabled
existing := s.settings.GetCrossDriveConfig(name)
if !system.IsMountPoint(cfg.DestinationPath) {
warnings = append(warnings,
fmt.Sprintf("A(z) %s mentési célja (%s) nincs csatlakoztatva!",
stackName, cfg.DestinationPath))
} else if !system.IsWritable(cfg.DestinationPath) {
warnings = append(warnings,
fmt.Sprintf("A(z) %s mentési célja (%s) nem írható!",
stackName, cfg.DestinationPath))
}
}
return warnings
cfg := settings.CrossDriveBackup{
Enabled: enabled,
}
if enabled {
// Read from form
cfg.DestinationPath = req.FormValue("cross_drive_dest")
cfg.Method = req.FormValue("cross_drive_method")
cfg.Schedule = req.FormValue("cross_drive_schedule")
} else if existing != nil {
// Preserve existing settings when disabling
cfg.DestinationPath = existing.DestinationPath
cfg.Method = existing.Method
cfg.Schedule = existing.Schedule
}
// Always preserve runtime state
if existing != nil {
cfg.LastRun = existing.LastRun
cfg.LastStatus = existing.LastStatus
cfg.LastError = existing.LastError
cfg.LastDuration = existing.LastDuration
cfg.LastSizeHuman = existing.LastSizeHuman
}
```
Include warnings in both the backup page and the hub report.
---
## Fix 5: Remove/reduce emojis
**Problem:** Too many emoji throughout the UI. Replace with text or CSS-styled elements.
### deploy.html changes:
| Location | Current | Replace with |
|----------|---------|-------------|
| Line ~16884 h4 | `🔒 Biztonsági mentés` | `Biztonsági mentés` |
| Line ~16902 warning | `⚠️ {{.BackupDestWarning}}` | `{{.BackupDestWarning}}` (the `.alert-warning` class already visually marks it as warning) |
| Line ~16964 status ok | `✅ Sikeres` | `Sikeres` |
| Line ~16964 status error | `❌ Hiba:` | `Hiba:` |
| Line ~16964 status running | `⏳ Fut...` | `Fut...` |
| Line ~16982 bottom hint | `⚠️ A cél meghajtó legyen...` | `A cél meghajtó legyen...` (already inside form-hint, visually distinct) |
| Stale data delete button | `🗑️ Korábbi adatok törlése` | `Korábbi adatok törlése` |
| Stale data h4 | `🗑️ Korábbi adatok` (if emoji present) | `Korábbi adatok` |
### backups.html cross-drive summary section changes:
| Location | Current | Replace with |
|----------|---------|-------------|
| Status ok badge | `✅ {{.LastRunShort}}` | `{{.LastRunShort}}` (use `.meta-badge-ok` class for green) |
| Status error badge | `❌ Hiba` | `Hiba` (use `.meta-badge-fail` class for red) |
| Running badge | `⏳ Fut...` | `Fut...` |
| Schedule badge | `⏰ {{.ScheduleLabel}}` | `{{.ScheduleLabel}}` |
| Unconfigured apps warning | `⚠️ {{len ...}}` | `{{len ...}}` (already in yellow-colored div) |
**Note:** The `.alert-warning` class already provides visual differentiation (yellow/orange background/border), and `.meta-badge-ok`/`.meta-badge-fail` provide green/red colors. Emojis are redundant with these CSS classes.
### Also check for any remaining emoji in:
- `stacks.html` — if any status emojis were left
- `backups.html` — the existing sections (db dumps, etc.)
---
## Safety Guards
## Fix 6 (bonus): "Automatikusan generálva" badge also uses emoji
1. **Destination ≠ Source**: Never allow backup destination to be the same storage path as the app's HDD_PATH
2. **Protected paths**: Use `ProtectedHDDPaths()` — never write to top-level dirs
3. **No parallel runs**: Mutex per app — skip if already running
4. **Free space check**: Before starting, verify destination has sufficient free space (source size × 1.1)
5. **rsync `--delete`**: Clearly warn user that rsync mirror deletes files on destination that were removed from source
6. **Restic password**: Auto-generated, stored in `settings.json`, displayed in backup page for recovery
In the auto-generated values section (line ~17031):
```html
<span class="auto-generated-badge">✓ Automatikusan generálva</span>
```
This is fine — the checkmark (`✓`) is a Unicode character, not an emoji. Leave as-is.
---
## Files Summary
## Summary of visual outcome
### New files (3)
| File | Purpose |
|------|---------|
| `internal/backup/crossdrive.go` | Cross-drive backup runner (rsync + restic) |
| `internal/backup/crossdrive_test.go` | Unit tests for path validation, config parsing |
| (templates inline changes) | |
**Before (v0.11.8):**
- Disabled checkbox that looks clickable
- Pointer cursor on non-interactive label
- All form fields visible/enabled even when backup disabled
- Emoji scattered throughout
- No explanation of rsync vs restic
- Tight spacing between sections
### Modified files (9)
| File | Changes |
|------|---------|
| `internal/settings/settings.go` | `CrossDriveBackup` struct, getter/setter methods, password storage |
| `internal/backup/appdata.go` | Add `CrossDriveConfig` to `AppBackupInfo` for backup page |
| `internal/backup/backup.go` | Add `CrossDriveRunner` field, wire into `Manager` |
| `internal/api/router.go` | 4 new API routes |
| `internal/web/handlers.go` | Deploy page data + backup page cross-drive summary |
| `internal/web/server.go` | Wire `CrossDriveRunner` |
| `internal/web/templates/deploy.html` | Backup config card for deployed apps |
| `internal/web/templates/backups.html` | Cross-drive summary section |
| `internal/web/templates/style.css` | Stale data margin fix + cross-drive styles |
| `main.go` | Create runner, register scheduler jobs |
**After (v0.11.9):**
- Clean green/gray dot indicator for nightly backup status
- Default cursor on non-interactive elements
- Dropdowns grayed out until "Engedélyezve" is checked → clear progressive disclosure
- No emoji — clean professional look using CSS classes for visual feedback
- Info tooltip on "Módszer" explaining both options clearly
- Proper spacing between all sections
- "Titkosított mentés" label better communicates restic's key advantage
---
## Testing
## Testing Checklist
### rsync method
1. Configure Immich rsync → hdd_1, daily
2. Trigger manual run → verify `/mnt/hdd_1/backups/rsync/immich/` created with data
3. Add a file to immich upload, run again → file appears in backup
4. Delete a file from immich, run again → file removed from backup (--delete)
5. Disconnect hdd_1 → warning shows on deploy page + backup page
### restic method
1. Configure Paperless restic → hdd_1, weekly
2. Trigger manual run → verify repo created at `/mnt/hdd_1/backups/restic/`
3. List snapshots: `restic -r /mnt/hdd_1/backups/restic/ snapshots --tag paperless-ngx`
4. Run again → second snapshot, dedup keeps repo small
5. Test restore: `restic -r /mnt/hdd_1/backups/restic/ restore latest --tag paperless-ngx --target /tmp/test`
### Scheduler
1. Set schedule daily → verify it runs after nightly backup
2. Set schedule weekly → verify it only runs on Sunday
3. Set schedule manual → verify it doesn't auto-run
### Destination monitoring
1. Configure backup to hdd_1 → no warnings
2. Unmount hdd_1 → warning appears on deploy + backup pages
3. Remount → warning clears
### Edge cases
1. App with no HDD data → backup card not shown
2. Only one storage path → "Másik adattároló szükséges" message
3. Source = destination → rejected with error
4. Destination full → error logged, status shows "error"
5. App migrated to new storage → backup source paths update automatically (reads from app.yaml)
1. Visit deployed app page (e.g., `/stacks/immich/deploy`)
2. Verify spacing between all cards is consistent
3. Verify no emoji visible in the backup section
4. Verify nightly backup shows green dot (if enabled) or gray dot (if not)
5. Verify cursor doesn't change to pointer on the nightly status line
6. Uncheck "Engedélyezve" → dropdowns should become disabled/grayed
7. Check "Engedélyezve" → dropdowns should become active
8. Save with "Engedélyezve" unchecked → verify existing config preserved (check settings.json)
9. Hover/focus on the (i) icon next to "Módszer" → tooltip appears with explanation
10. Check backup page → no emoji in cross-drive summary section