v0.11.9 UI Polish Fixes
This commit is contained in:
@@ -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):
|
**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.
|
||||||
- **rsync** — Simple file mirror. Easy to browse via FileBrowser. No versioning.
|
|
||||||
- **restic** — Versioned, encrypted, deduplicated snapshots on the secondary drive.
|
|
||||||
|
|
||||||
### Current State (what already exists)
|
## Files to modify
|
||||||
|
|
||||||
| Feature | Status | Location |
|
- `internal/web/templates/deploy.html`
|
||||||
|---------|--------|----------|
|
- `internal/web/templates/style.css`
|
||||||
| Per-app backup toggle | ✅ Exists | Backup page, `settings.json` `app_backup` map |
|
- `internal/web/templates/backups.html` (emoji cleanup in cross-drive summary section)
|
||||||
| `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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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
|
**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.
|
||||||
// AppBackupPrefs holds per-app backup configuration.
|
|
||||||
type AppBackupPrefs struct {
|
|
||||||
// Existing: includes app data in nightly restic (same drive)
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
|
|
||||||
// NEW: Cross-drive backup to secondary storage
|
**Fix in `style.css`:**
|
||||||
CrossDrive *CrossDriveBackup `json:"cross_drive,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CrossDriveBackup configures per-app backup to a secondary drive.
|
```css
|
||||||
type CrossDriveBackup struct {
|
/* Change margin-bottom from 1rem to 1.5rem */
|
||||||
Enabled bool `json:"enabled"`
|
.deploy-cross-drive {
|
||||||
Method string `json:"method"` // "rsync" or "restic"
|
/* ... existing ... */
|
||||||
DestinationPath string `json:"destination_path"` // e.g., "/mnt/hdd_1"
|
margin-bottom: 1.5rem; /* was 1rem */
|
||||||
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"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Example `settings.json`:
|
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).
|
||||||
```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)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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
|
**Fix in `deploy.html` — method dropdown (around line 16934-16942):**
|
||||||
// CrossDriveRunner handles per-app backup to secondary storage.
|
|
||||||
type CrossDriveRunner struct {
|
Replace:
|
||||||
settings *settings.Settings
|
```html
|
||||||
stackProvider StackDataProvider
|
<div class="settings-row">
|
||||||
logger *log.Logger
|
<span class="settings-label">Módszer</span>
|
||||||
mu sync.Mutex
|
<select name="cross_drive_method" class="form-control" style="max-width:20rem">
|
||||||
running map[string]bool // per-app running state
|
<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.
|
.info-icon {
|
||||||
func (r *CrossDriveRunner) RunAppBackup(ctx context.Context, stackName string) error
|
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.
|
.info-tooltip-text {
|
||||||
func (r *CrossDriveRunner) RunAllScheduled(ctx context.Context, schedule string) error
|
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.
|
/* Show on hover or focus (for keyboard) */
|
||||||
func (r *CrossDriveRunner) GetAppStatus(stackName string) *CrossDriveStatus
|
.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):**
|
||||||
|
|
||||||
```
|
Replace:
|
||||||
┌────────────────────────────────────────────────────────┐
|
```html
|
||||||
│ 🔒 Biztonsági mentés │
|
<div class="cross-drive-nightly">
|
||||||
│ │
|
<label class="toggle">
|
||||||
│ ☑ Napi mentésbe foglalás (restic, helyi) │
|
<input type="checkbox" id="app-backup-enabled" {{if .AppBackupEnabled}}checked{{end}} disabled>
|
||||||
│ Az alkalmazás adatai bekerülnek az éjszakai │
|
<span class="toggle-label">Napi mentésbe foglalás (restic, helyi)</span>
|
||||||
│ biztonsági mentésbe. │
|
</label>
|
||||||
│ │
|
|
||||||
│ ───────────────────────────────────────────── │
|
|
||||||
│ │
|
|
||||||
│ 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. │
|
|
||||||
└────────────────────────────────────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**States:**
|
With:
|
||||||
- **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."
|
```html
|
||||||
- **Other path available but not configured:** Dropdowns shown, save button active
|
<div class="cross-drive-nightly">
|
||||||
- **Configured and healthy:** Shows last run status, manual trigger available
|
<div class="cross-drive-nightly-status">
|
||||||
- **Configured but destination unreachable:** Red warning: "⚠️ A cél tárhely ({path}) nem elérhető! Ellenőrizd a meghajtó csatlakozását."
|
{{if .AppBackupEnabled}}
|
||||||
|
<span class="nightly-status-indicator nightly-enabled"></span>
|
||||||
### 2. Backup Page — Summary Card
|
{{else}}
|
||||||
|
<span class="nightly-status-indicator nightly-disabled"></span>
|
||||||
On the central "Biztonsági mentés" page, add a new section after "Alkalmazás adatok":
|
{{end}}
|
||||||
|
<span class="toggle-label">Napi mentésbe foglalás (restic, helyi)</span>
|
||||||
```
|
</div>
|
||||||
┌────────────────────────────────────────────────────────┐
|
|
||||||
│ 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] │
|
|
||||||
└────────────────────────────────────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Each row links to the app's deploy/settings page. Shows warnings for:
|
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.
|
||||||
- Apps with HDD data but no cross-drive backup configured
|
|
||||||
- Destinations that are unreachable/unmounted
|
**Add to `style.css`:**
|
||||||
- Last run failures
|
|
||||||
|
```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 |
|
**Fix in `deploy.html` — add JS to toggle field states:**
|
||||||
|--------|------|------|-------------|
|
|
||||||
| 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 |
|
|
||||||
|
|
||||||
### New web handler
|
Add `onchange` handler to the enabled checkbox and `disabled` attribute to the select fields:
|
||||||
|
|
||||||
| 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":
|
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<!-- Section 5: Cross-drive backups -->
|
<div class="settings-row">
|
||||||
{{if .Backup.CrossDriveSummary}}
|
<span class="settings-label">Engedélyezve</span>
|
||||||
<div class="backup-section-card">
|
<label class="toggle" style="margin:0">
|
||||||
<h3>Másolatok másik meghajtóra</h3>
|
<input type="checkbox" name="cross_drive_enabled" id="cross-drive-enabled"
|
||||||
<p class="backup-section-desc">Alkalmazás adatok biztonsági másolata külső meghajtóra.</p>
|
{{if and .CrossDriveConfig .CrossDriveConfig.Enabled}}checked{{end}}
|
||||||
|
onchange="toggleCrossDriveFields()">
|
||||||
{{if .Backup.CrossDriveWarnings}}
|
<span class="toggle-label">Igen</span>
|
||||||
<div class="alert alert-warning" style="margin-bottom:1rem">
|
</label>
|
||||||
{{range .Backup.CrossDriveWarnings}}
|
|
||||||
<div>{{.}}</div>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
<div class="settings-row">
|
||||||
|
<span class="settings-label">Cél tárhely</span>
|
||||||
<div class="cross-drive-list">
|
<select name="cross_drive_dest" id="cd-dest" class="form-control cross-drive-field" style="max-width:20rem"
|
||||||
{{range .Backup.CrossDriveSummary}}
|
{{if not (and .CrossDriveConfig .CrossDriveConfig.Enabled)}}disabled{{end}}>
|
||||||
<div class="cross-drive-item">
|
...options...
|
||||||
<div class="cross-drive-header">
|
</select>
|
||||||
<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 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>
|
||||||
|
<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>
|
</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>
|
|
||||||
{{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:
|
```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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**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).
|
||||||
|
|
||||||
|
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")
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
```go
|
||||||
// Cross-drive backup summary
|
enabled := req.FormValue("cross_drive_enabled") == "on"
|
||||||
type CrossDriveSummaryItem struct {
|
|
||||||
StackName string
|
// Load existing config to preserve values when disabled
|
||||||
DisplayName string
|
existing := s.settings.GetCrossDriveConfig(name)
|
||||||
Method string // "rsync" or "restic"
|
|
||||||
MethodLabel string // "Egyszerű másolat" or "Restic"
|
cfg := settings.CrossDriveBackup{
|
||||||
DestPath string
|
Enabled: enabled,
|
||||||
DestLabel string
|
}
|
||||||
Schedule string
|
|
||||||
ScheduleLabel string // "Naponta" or "Hetente"
|
if enabled {
|
||||||
LastStatus string
|
// Read from form
|
||||||
LastRunShort string
|
cfg.DestinationPath = req.FormValue("cross_drive_dest")
|
||||||
SizeHuman string
|
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
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 7: Destination health monitoring
|
---
|
||||||
|
|
||||||
**File:** `internal/web/handlers.go` or `internal/monitor/health.go`
|
## Fix 5: Remove/reduce emojis
|
||||||
|
|
||||||
In the periodic health check (runs every 5 min), also check cross-drive destinations:
|
**Problem:** Too many emoji throughout the UI. Replace with text or CSS-styled elements.
|
||||||
|
|
||||||
```go
|
### deploy.html changes:
|
||||||
func (s *Server) checkCrossDriveDestinations() []string {
|
|
||||||
var warnings []string
|
|
||||||
configs := s.settings.GetAllCrossDriveConfigs()
|
|
||||||
seen := make(map[string]bool)
|
|
||||||
|
|
||||||
for stackName, cfg := range configs {
|
| Location | Current | Replace with |
|
||||||
if !cfg.Enabled || cfg.DestinationPath == "" || seen[cfg.DestinationPath] {
|
|----------|---------|-------------|
|
||||||
continue
|
| 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) |
|
||||||
seen[cfg.DestinationPath] = true
|
| 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` |
|
||||||
|
|
||||||
if !system.IsMountPoint(cfg.DestinationPath) {
|
### backups.html cross-drive summary section changes:
|
||||||
warnings = append(warnings,
|
|
||||||
fmt.Sprintf("A(z) %s mentési célja (%s) nincs csatlakoztatva!",
|
| Location | Current | Replace with |
|
||||||
stackName, cfg.DestinationPath))
|
|----------|---------|-------------|
|
||||||
} else if !system.IsWritable(cfg.DestinationPath) {
|
| Status ok badge | `✅ {{.LastRunShort}}` | `{{.LastRunShort}}` (use `.meta-badge-ok` class for green) |
|
||||||
warnings = append(warnings,
|
| Status error badge | `❌ Hiba` | `Hiba` (use `.meta-badge-fail` class for red) |
|
||||||
fmt.Sprintf("A(z) %s mentési célja (%s) nem írható!",
|
| Running badge | `⏳ Fut...` | `Fut...` |
|
||||||
stackName, cfg.DestinationPath))
|
| Schedule badge | `⏰ {{.ScheduleLabel}}` | `{{.ScheduleLabel}}` |
|
||||||
}
|
| Unconfigured apps warning | `⚠️ {{len ...}}` | `{{len ...}}` (already in yellow-colored div) |
|
||||||
}
|
|
||||||
return warnings
|
**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.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fix 6 (bonus): "Automatikusan generálva" badge also uses emoji
|
||||||
|
|
||||||
|
In the auto-generated values section (line ~17031):
|
||||||
|
```html
|
||||||
|
<span class="auto-generated-badge">✓ Automatikusan generálva</span>
|
||||||
```
|
```
|
||||||
|
|
||||||
Include warnings in both the backup page and the hub report.
|
This is fine — the checkmark (`✓`) is a Unicode character, not an emoji. Leave as-is.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Safety Guards
|
## Summary of visual outcome
|
||||||
|
|
||||||
1. **Destination ≠ Source**: Never allow backup destination to be the same storage path as the app's HDD_PATH
|
**Before (v0.11.8):**
|
||||||
2. **Protected paths**: Use `ProtectedHDDPaths()` — never write to top-level dirs
|
- Disabled checkbox that looks clickable
|
||||||
3. **No parallel runs**: Mutex per app — skip if already running
|
- Pointer cursor on non-interactive label
|
||||||
4. **Free space check**: Before starting, verify destination has sufficient free space (source size × 1.1)
|
- All form fields visible/enabled even when backup disabled
|
||||||
5. **rsync `--delete`**: Clearly warn user that rsync mirror deletes files on destination that were removed from source
|
- Emoji scattered throughout
|
||||||
6. **Restic password**: Auto-generated, stored in `settings.json`, displayed in backup page for recovery
|
- No explanation of rsync vs restic
|
||||||
|
- Tight spacing between sections
|
||||||
|
|
||||||
---
|
**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
|
||||||
|
|
||||||
## Files Summary
|
## Testing
|
||||||
|
|
||||||
### New files (3)
|
1. Visit deployed app page (e.g., `/stacks/immich/deploy`)
|
||||||
| File | Purpose |
|
2. Verify spacing between all cards is consistent
|
||||||
|------|---------|
|
3. Verify no emoji visible in the backup section
|
||||||
| `internal/backup/crossdrive.go` | Cross-drive backup runner (rsync + restic) |
|
4. Verify nightly backup shows green dot (if enabled) or gray dot (if not)
|
||||||
| `internal/backup/crossdrive_test.go` | Unit tests for path validation, config parsing |
|
5. Verify cursor doesn't change to pointer on the nightly status line
|
||||||
| (templates inline changes) | |
|
6. Uncheck "Engedélyezve" → dropdowns should become disabled/grayed
|
||||||
|
7. Check "Engedélyezve" → dropdowns should become active
|
||||||
### Modified files (9)
|
8. Save with "Engedélyezve" unchecked → verify existing config preserved (check settings.json)
|
||||||
| File | Changes |
|
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
|
||||||
| `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 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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)
|
|
||||||
Reference in New Issue
Block a user