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}} - +