From 2befa6877b485ad97f8799d3880192e8fc33a487 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Thu, 19 Feb 2026 08:23:33 +0100 Subject: [PATCH] =?UTF-8?q?v0.15.1:=20Backup=20page=20R=C3=A9szletek=20ove?= =?UTF-8?q?rhaul=20with=20per-drive=20tier=20sections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace Tároló section with collapsible Részletek containing 3 tiers: - Tier 1: per-drive restic repo stats with storage labels - Tier 2: cross-drive items grouped by destination, split by method - Tier 3: remote backup placeholder Restore UI now shows tier + drive labels in snapshot dropdown. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 17 ++ controller/internal/api/router.go | 20 +- controller/internal/backup/backup.go | 102 +++++++- controller/internal/backup/restic.go | 12 +- controller/internal/web/handlers.go | 60 ++++- .../internal/web/templates/backups.html | 244 ++++++++++++------ controller/internal/web/templates/style.css | 59 +++++ 7 files changed, 415 insertions(+), 99 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33e1642..45032e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ ## Changelog +### What was just completed (2026-02-19 session 51) +- **v0.15.1 — Backup Page "Részletek" Overhaul:** + + Replaced the "Tároló" section on the backup page with a new "Részletek" section containing 3 collapsible tier sections with per-drive breakdowns. + + **Tier 1 (Helyi mentés):** Shows per-drive restic repo stats (size, snapshot count) with storage labels. Includes aggregated totals when multiple drives exist, plus DB dump summary, integrity check, and encryption key (all carried over). + + **Tier 2 (Másodlagos másolat):** Groups cross-drive backup items by destination drive, separated into restic and rsync method sections with per-app sizes. + + **Tier 3 (Távoli mentés):** Placeholder for future B2/S3/SFTP remote backup. + + **Restore UI improvements:** Snapshot dropdown now groups by tier (optgroup), shows tier label + drive name per snapshot (e.g., "1. szint, hdd_1"), and marks Tier 1 as recommended. Also lists Tier 2 (secondary restic) snapshots for visibility. + + **Backend:** New `DriveRepoInfo` struct, `perDriveRepoStats()` method, `ListAllSnapshots()` that includes secondary restic repos, and `Tier2DriveGroup` handler struct. `SnapshotInfo` now carries `Tier` and `DriveLabel` fields. + + **Files modified (5):** `internal/backup/backup.go`, `internal/backup/restic.go`, `internal/web/handlers.go`, `internal/api/router.go`, `internal/web/templates/backups.html`, `internal/web/templates/style.css` + ### What was just completed (2026-02-18 session 50) - **v0.15.0 — Attach Existing Drive (bind mount wizard):** diff --git a/controller/internal/api/router.go b/controller/internal/api/router.go index 1edd6d3..6c7d88d 100644 --- a/controller/internal/api/router.go +++ b/controller/internal/api/router.go @@ -451,17 +451,25 @@ func (r *Router) backupSnapshots(w http.ResponseWriter, req *http.Request) { return } - snapshots, err := r.backupMgr.ListSnapshots(50) + snapshots, err := r.backupMgr.ListAllSnapshots(50) if err != nil { writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()}) return } - // All snapshots contain the stacks dir + DB dumps, so they're useful for - // any app (config + DB restore). Apps with HDD data get user data restored - // too — but only from snapshots that include those paths (post-v0.12.7). - // We don't filter here because older snapshots still allow config+DB restore, - // and the RestoreApp function extracts whatever paths are available. + // Enrich snapshots with drive labels from storage paths + if r.sett != nil { + storagePaths := r.sett.GetStoragePaths() + for i := range snapshots { + repoPath := snapshots[i].RepoPath + for _, sp := range storagePaths { + if strings.HasPrefix(repoPath, sp.Path) { + snapshots[i].DriveLabel = sp.Label + break + } + } + } + } if snapshots == nil { snapshots = []backup.SnapshotInfo{} diff --git a/controller/internal/backup/backup.go b/controller/internal/backup/backup.go index a4f1dfd..64601dc 100644 --- a/controller/internal/backup/backup.go +++ b/controller/internal/backup/backup.go @@ -55,6 +55,15 @@ type SnapshotRecord struct { HasStats bool `json:"has_stats"` // false for historical entries loaded from restic } +// DriveRepoInfo holds per-drive restic repository statistics for the Részletek section. +type DriveRepoInfo struct { + DrivePath string + DriveLabel string // filled by handler from settings + TotalSize string + TotalSizeBytes int64 + SnapshotCount int +} + // CrossDriveSummaryItem holds display data for one app's cross-drive backup. type CrossDriveSummaryItem struct { StackName string @@ -81,9 +90,10 @@ type FullBackupStatus struct { DiscoveredDBs []DiscoveredDB // Restic - LastBackup *BackupStatus - SnapshotHistory []SnapshotRecord - RepoStats *RepoStats + LastBackup *BackupStatus + SnapshotHistory []SnapshotRecord + RepoStats *RepoStats + PerDriveRepoStats []DriveRepoInfo // per-drive Tier 1 restic stats // Schedule DBDumpSchedule string @@ -565,6 +575,66 @@ func (m *Manager) ListSnapshots(limit int) ([]SnapshotInfo, error) { return allSnapshots, nil } +// ListAllSnapshots returns snapshots from both primary and secondary restic repos. +// Primary snapshots get Tier=1, secondary snapshots get Tier=2. +func (m *Manager) ListAllSnapshots(limit int) ([]SnapshotInfo, error) { + drives := m.activeDrives() + var allSnapshots []SnapshotInfo + + // Tier 1: primary repos (same as ListSnapshots) + for _, drive := range drives { + repoPath := PrimaryResticRepoPath(drive) + if !m.restic.RepoExists(repoPath) { + continue + } + snapshots, err := m.restic.ListSnapshots(repoPath, 0) + if err != nil { + m.logger.Printf("[WARN] Could not list snapshots from %s: %v", repoPath, err) + continue + } + for i := range snapshots { + snapshots[i].RepoPath = repoPath + snapshots[i].Tier = 1 + } + allSnapshots = append(allSnapshots, snapshots...) + } + + // Tier 2: secondary restic repos on cross-drive destinations + if m.settings != nil { + destPaths := make(map[string]bool) + for _, cfg := range m.settings.GetAllCrossDriveConfigs() { + if cfg != nil && cfg.Method == "restic" && cfg.DestinationPath != "" { + destPaths[cfg.DestinationPath] = true + } + } + for destPath := range destPaths { + repoPath := SecondaryResticRepoPath(destPath) + if !m.restic.RepoExists(repoPath) { + continue + } + snapshots, err := m.restic.ListSnapshots(repoPath, 0) + if err != nil { + m.logger.Printf("[WARN] Could not list secondary snapshots from %s: %v", repoPath, err) + continue + } + for i := range snapshots { + snapshots[i].RepoPath = repoPath + snapshots[i].Tier = 2 + } + allSnapshots = append(allSnapshots, snapshots...) + } + } + + // Sort newest first + sort.Slice(allSnapshots, func(i, j int) bool { + return allSnapshots[i].Time.After(allSnapshots[j].Time) + }) + if limit > 0 && len(allSnapshots) > limit { + allSnapshots = allSnapshots[:limit] + } + return allSnapshots, nil +} + // SetStackProvider sets the stack data provider for app data discovery. // C3: Write is protected by mutex since stackProvider is read by concurrent goroutines. func (m *Manager) SetStackProvider(provider StackDataProvider) { @@ -632,6 +702,29 @@ func (m *Manager) DumpStackDB(ctx context.Context, stackName string) error { return nil } +// perDriveRepoStats returns per-drive restic repository statistics for the Részletek section. +func (m *Manager) perDriveRepoStats() []DriveRepoInfo { + drives := m.activeDrives() + var infos []DriveRepoInfo + for _, drive := range drives { + repoPath := PrimaryResticRepoPath(drive) + if !m.restic.RepoExists(repoPath) { + continue + } + stats, err := m.restic.Stats(repoPath) + if err != nil { + continue + } + infos = append(infos, DriveRepoInfo{ + DrivePath: drive, + TotalSize: stats.TotalSize, + TotalSizeBytes: stats.TotalSizeBytes, + SnapshotCount: stats.SnapshotCount, + }) + } + return infos +} + // aggregateRepoStats combines stats from all primary restic repos. func (m *Manager) aggregateRepoStats() *RepoStats { drives := m.activeDrives() @@ -758,6 +851,7 @@ func (m *Manager) RefreshCache(nextDBDump, nextBackup time.Time) { // Expensive calls (outside lock) status.RepoStats = m.aggregateRepoStats() + status.PerDriveRepoStats = m.perDriveRepoStats() // Scan dump files from per-drive per-stack paths files := m.listAllDumpFiles() @@ -831,6 +925,8 @@ func (m *Manager) GetFullStatus(nextDBDump, nextBackup time.Time) *FullBackupSta status := *m.cachedStatus status.AppDataInfo = make([]AppBackupInfo, len(m.cachedStatus.AppDataInfo)) copy(status.AppDataInfo, m.cachedStatus.AppDataInfo) + status.PerDriveRepoStats = make([]DriveRepoInfo, len(m.cachedStatus.PerDriveRepoStats)) + copy(status.PerDriveRepoStats, m.cachedStatus.PerDriveRepoStats) // These three slices are assembled by the handler from AppDataInfo + settings; // they must always start empty so the handler builds them fresh. status.CrossDriveSummary = nil diff --git a/controller/internal/backup/restic.go b/controller/internal/backup/restic.go index ba1d145..63e7c91 100644 --- a/controller/internal/backup/restic.go +++ b/controller/internal/backup/restic.go @@ -36,11 +36,13 @@ type SnapshotResult struct { // SnapshotInfo holds information about a restic snapshot. type SnapshotInfo struct { - ID string `json:"short_id"` - Time time.Time `json:"time"` - Paths []string `json:"paths"` - Tags []string `json:"tags"` - RepoPath string `json:"-"` // set by caller for multi-repo aggregation + ID string `json:"short_id"` + Time time.Time `json:"time"` + Paths []string `json:"paths"` + Tags []string `json:"tags"` + RepoPath string `json:"-"` // set by caller for multi-repo aggregation + Tier int `json:"tier"` // 1 = primary, 2 = secondary + DriveLabel string `json:"drive_label"` // filled by caller from settings } // RepoStats holds repository statistics. diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 42f401d..04eedeb 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -525,33 +525,59 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) { data["ResticPassword"] = pw } - // Tároló section: DB dump total size + // Részletek section: DB dump total size var dbDumpTotalBytes int64 for _, f := range fullStatus.DumpFiles { dbDumpTotalBytes += f.Size } data["DBDumpTotalBytes"] = dbDumpTotalBytes - // Tároló section: deduplicated Tier 2 destination list - tier2DestMap := make(map[string]map[string]string) + // Részletek section: enrich per-drive repo stats with storage labels + for i := range fullStatus.PerDriveRepoStats { + for _, sp := range storagePaths { + if strings.HasPrefix(fullStatus.PerDriveRepoStats[i].DrivePath, sp.Path) || + fullStatus.PerDriveRepoStats[i].DrivePath == sp.Path { + fullStatus.PerDriveRepoStats[i].DriveLabel = sp.Label + break + } + } + if fullStatus.PerDriveRepoStats[i].DriveLabel == "" { + fullStatus.PerDriveRepoStats[i].DriveLabel = filepath.Base(fullStatus.PerDriveRepoStats[i].DrivePath) + } + } + data["PerDriveRepoStats"] = fullStatus.PerDriveRepoStats + + // Részletek section: group Tier 2 items by destination drive + tier2GroupMap := make(map[string]*Tier2DriveGroup) for _, item := range fullStatus.CrossDriveSummary { if item.DestPath == "" { continue } - if _, exists := tier2DestMap[item.DestPath]; !exists { - tier2DestMap[item.DestPath] = map[string]string{ - "Path": item.DestPath, - "Label": item.DestLabel, - "Method": item.MethodLabel, - "SizeHuman": item.SizeHuman, + grp, exists := tier2GroupMap[item.DestPath] + if !exists { + grp = &Tier2DriveGroup{ + DestPath: item.DestPath, + DestLabel: item.DestLabel, } + if grp.DestLabel == "" { + grp.DestLabel = filepath.Base(item.DestPath) + } + tier2GroupMap[item.DestPath] = grp + } + switch item.Method { + case "restic": + grp.ResticItems = append(grp.ResticItems, item) + case "rsync": + grp.RsyncItems = append(grp.RsyncItems, item) + default: + grp.RsyncItems = append(grp.RsyncItems, item) } } - var tier2DestList []map[string]string - for _, d := range tier2DestMap { - tier2DestList = append(tier2DestList, d) + var tier2Groups []Tier2DriveGroup + for _, grp := range tier2GroupMap { + tier2Groups = append(tier2Groups, *grp) } - data["Tier2Dests"] = tier2DestList + data["Tier2DriveGroups"] = tier2Groups } else { data["Backup"] = nil } @@ -559,6 +585,14 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) { s.render(w, "backups", data) } +// Tier2DriveGroup holds grouped Tier 2 cross-drive backup items for one destination drive. +type Tier2DriveGroup struct { + DestPath string + DestLabel string + ResticItems []backup.CrossDriveSummaryItem + RsyncItems []backup.CrossDriveSummaryItem +} + // AppBackupRow holds per-tier backup information for one app on the backup page. type AppBackupRow struct { StackName string diff --git a/controller/internal/web/templates/backups.html b/controller/internal/web/templates/backups.html index 4f52d1a..6da4dc8 100644 --- a/controller/internal/web/templates/backups.html +++ b/controller/internal/web/templates/backups.html @@ -373,82 +373,143 @@ {{end}} - +
-

Tároló

+

Részletek

- -
-

1. mentés — Helyi mentés (restic)

-
+ +
+
+ +

1. szint — Helyi mentés (restic)

+
+
+ {{if .PerDriveRepoStats}} + {{range .PerDriveRepoStats}} +
+
{{.DriveLabel}}
+
+
+ Méret: + {{if .TotalSize}}{{.TotalSize}}{{else}}—{{end}} +
+
+ Pillanatképek: + {{.SnapshotCount}} +
+
+
+ {{end}} + {{if gt (len .PerDriveRepoStats) 1}} +
+
+ Összesen: + {{if .Backup.RepoStats}}{{.Backup.RepoStats.TotalSize}} · {{.Backup.RepoStats.SnapshotCount}} pillanatkép{{end}} +
+
+ {{end}} + {{else}} {{if .Backup.RepoStats}} -
- Méret: - {{.Backup.RepoStats.TotalSize}} -
-
- Pillanatképek: - {{.Backup.RepoStats.SnapshotCount}} +
+
+ Méret: + {{.Backup.RepoStats.TotalSize}} +
+
+ Pillanatképek: + {{.Backup.RepoStats.SnapshotCount}} +
{{end}} -
- Adatbázis mentések: - {{if .Backup.DumpFiles}}{{len .Backup.DumpFiles}} dump fájl{{if gt .DBDumpTotalBytes 0}} — {{fmtBytes .DBDumpTotalBytes}}{{end}}{{else}}Nincs dump fájl{{end}} + {{end}} + +
+
+ Adatbázis mentések: + {{if .Backup.DumpFiles}}{{len .Backup.DumpFiles}} dump fájl{{if gt .DBDumpTotalBytes 0}} — {{fmtBytes .DBDumpTotalBytes}}{{end}}{{else}}Nincs dump fájl{{end}} +
+
+ Integritás: + + {{if .Backup.LastCheckTime.IsZero}} + Még nem ellenőrzött + {{else if .Backup.LastCheckOK}} + Rendben ({{fmtTime .Backup.LastCheckTime}}) + {{else}} + Hiba ({{fmtTime .Backup.LastCheckTime}}) + {{end}} + +
-
- Integritás: - - {{if .Backup.LastCheckTime.IsZero}} - Még nem ellenőrzött - {{else if .Backup.LastCheckOK}} - Rendben ({{fmtTime .Backup.LastCheckTime}}) - {{else}} - Hiba ({{fmtTime .Backup.LastCheckTime}}) + + + {{if $.ResticPassword}} +
+ Titkosítási kulcs: +
+ + + +
+
+ Mentse el biztonságos helyre! A kulcs nélkül a biztonsági mentések NEM állíthatók vissza. +
+
+ {{end}} +
+
+ + +
+
+ +

2. szint — Másodlagos másolat

+
+ - - - {{if .Tier2Dests}} -
-

2. mentés — Másodlagos másolat

- {{range .Tier2Dests}} -
-
- Cél: - {{index . "Path"}}{{if index . "Label"}} ({{index . "Label"}}){{end}} -
-
- Módszer: - {{index . "Method"}} -
- {{if index . "SizeHuman"}} -
- Méret: - {{index . "SizeHuman"}} +
+ {{end}} + {{if .RsyncItems}} +
+
Rsync:
+ {{range .RsyncItems}} +
+ {{.DisplayName}} + {{if .SizeHuman}}{{.SizeHuman}}{{else}}—{{end}} +
+ {{end}} +
+ {{end}}
{{end}} + {{else}} +
Nincs 2. szintű mentés konfigurálva.
+ {{end}} +
+
+ + +
+
+ +

3. szint — Távoli mentés (offsite)

+
+ - {{end}}
- {{end}}
@@ -508,6 +569,18 @@ function toggleBackupDetail(header) { } } +function toggleTier(header) { + var body = header.nextElementSibling; + var icon = header.querySelector('.expand-icon'); + if (body.style.display === 'none') { + body.style.display = 'block'; + icon.textContent = '▼'; + } else { + body.style.display = 'none'; + icon.textContent = '▶'; + } +} + function triggerCrossDriveBackup(stackName, btn) { btn.disabled = true; btn.textContent = 'Fut...'; @@ -607,9 +680,16 @@ var huDays = ['vasárnap', 'hétfő', 'kedd', 'szerda', 'csütörtök', 'péntek function formatSnapshot(s) { var t = new Date(s.time); var pad = function(n) { return n < 10 ? '0' + n : '' + n; }; - return t.getFullYear() + '-' + pad(t.getMonth()+1) + '-' + pad(t.getDate()) + + var label = t.getFullYear() + '-' + pad(t.getMonth()+1) + '-' + pad(t.getDate()) + ' ' + huDays[t.getDay()] + ' ' + pad(t.getHours()) + ':' + pad(t.getMinutes()) + ' (' + s.short_id + ')'; + var tierLabel = s.tier === 2 ? '2. szint' : '1. szint'; + if (s.drive_label) { + label += ' — ' + tierLabel + ', ' + s.drive_label; + } else { + label += ' — ' + tierLabel; + } + return label; } function onRestoreAppChange() { @@ -653,12 +733,32 @@ function onRestoreAppChange() { .then(function(data) { snapSel.innerHTML = ''; if (data.ok && data.data && data.data.length > 0) { - data.data.forEach(function(s) { - var o = document.createElement('option'); - o.value = s.short_id; - o.textContent = formatSnapshot(s); - snapSel.appendChild(o); - }); + // Group by tier + var tier1 = data.data.filter(function(s) { return s.tier !== 2; }); + var tier2 = data.data.filter(function(s) { return s.tier === 2; }); + + if (tier1.length > 0) { + var grp1 = document.createElement('optgroup'); + grp1.label = '1. szint — Helyi mentés (ajánlott)'; + tier1.forEach(function(s) { + var o = document.createElement('option'); + o.value = s.short_id; + o.textContent = formatSnapshot(s); + grp1.appendChild(o); + }); + snapSel.appendChild(grp1); + } + if (tier2.length > 0) { + var grp2 = document.createElement('optgroup'); + grp2.label = '2. szint — Másodlagos másolat'; + tier2.forEach(function(s) { + var o = document.createElement('option'); + o.value = s.short_id; + o.textContent = formatSnapshot(s); + grp2.appendChild(o); + }); + snapSel.appendChild(grp2); + } } else { snapSel.innerHTML = ''; noSnaps.style.display = 'block'; diff --git a/controller/internal/web/templates/style.css b/controller/internal/web/templates/style.css index 16bc4f9..5f083df 100644 --- a/controller/internal/web/templates/style.css +++ b/controller/internal/web/templates/style.css @@ -1600,6 +1600,65 @@ a.stat-card:hover { gap: .15rem; } +/* Details tier (collapsible sections in Részletek) */ +.details-tier { + border-top: 1px solid var(--border-color); + margin-top: .5rem; +} +.details-tier:first-child { + border-top: none; + margin-top: 0; +} +.details-tier-header { + display: flex; + align-items: center; + gap: .5rem; + cursor: pointer; + padding: .5rem 0; + user-select: none; +} +.details-tier-header:hover { + opacity: .8; +} +.details-tier-header .expand-icon { + font-size: .7rem; + color: var(--text-muted); + min-width: 1rem; + text-align: center; +} +.details-tier-header .repo-tier-title { + margin-bottom: 0; +} +.details-tier-body { + padding-bottom: .75rem; +} +.drive-detail-card { + background: var(--bg-secondary); + border-radius: var(--radius); + padding: .75rem; + margin-bottom: .5rem; +} +.drive-detail-header { + font-size: .85rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: .5rem; +} +.method-group { + margin-top: .5rem; +} +.method-group-label { + font-size: .8rem; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: .25rem; +} +.tier-empty-state { + font-size: .85rem; + color: var(--text-muted); + padding: .5rem 0; +} + .relative-time { color: var(--text-muted); font-size: .8rem;