v0.15.1: Backup page Részletek overhaul with per-drive tier sections

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 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 08:23:33 +01:00
parent 0c0cacbe7c
commit 2befa6877b
7 changed files with 415 additions and 99 deletions
+17
View File
@@ -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):**
+14 -6
View File
@@ -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{}
+96
View File
@@ -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
@@ -84,6 +93,7 @@ type FullBackupStatus struct {
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
+2
View File
@@ -41,6 +41,8 @@ type SnapshotInfo struct {
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.
+48 -14
View File
@@ -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 tier2Groups []Tier2DriveGroup
for _, grp := range tier2GroupMap {
tier2Groups = append(tier2Groups, *grp)
}
var tier2DestList []map[string]string
for _, d := range tier2DestMap {
tier2DestList = append(tier2DestList, d)
}
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
+122 -22
View File
@@ -373,15 +373,44 @@
{{end}}
</div>
<!-- Section 6: Repository -->
<!-- Section 6: Részletek (Details) -->
<div class="repo-card">
<h3>Tároló</h3>
<h3>Részletek</h3>
<!-- Tier 1: Local restic backup (per-drive) -->
<div class="repo-tier">
<h4 class="repo-tier-title">1. mentés — Helyi mentés (restic)</h4>
<!-- Tier 1: Helyi mentés (collapsible, open by default) -->
<div class="details-tier">
<div class="details-tier-header" onclick="toggleTier(this)">
<span class="expand-icon"></span>
<h4 class="repo-tier-title">1. szint — Helyi mentés (restic)</h4>
</div>
<div class="details-tier-body">
{{if .PerDriveRepoStats}}
{{range .PerDriveRepoStats}}
<div class="drive-detail-card">
<div class="drive-detail-header">{{.DriveLabel}}</div>
<div class="repo-info-rows">
<div class="repo-info-row">
<span class="repo-label">Méret:</span>
<span class="repo-value">{{if .TotalSize}}{{.TotalSize}}{{else}}—{{end}}</span>
</div>
<div class="repo-info-row">
<span class="repo-label">Pillanatképek:</span>
<span class="repo-value">{{.SnapshotCount}}</span>
</div>
</div>
</div>
{{end}}
{{if gt (len .PerDriveRepoStats) 1}}
<div class="repo-info-rows" style="margin-top:0.5rem;padding-top:0.5rem;border-top:1px solid var(--border-color)">
<div class="repo-info-row">
<span class="repo-label">Összesen:</span>
<span class="repo-value">{{if .Backup.RepoStats}}{{.Backup.RepoStats.TotalSize}} · {{.Backup.RepoStats.SnapshotCount}} pillanatkép{{end}}</span>
</div>
</div>
{{end}}
{{else}}
{{if .Backup.RepoStats}}
<div class="repo-info-rows">
<div class="repo-info-row">
<span class="repo-label">Méret:</span>
<span class="repo-value">{{.Backup.RepoStats.TotalSize}}</span>
@@ -390,7 +419,11 @@
<span class="repo-label">Pillanatképek:</span>
<span class="repo-value">{{.Backup.RepoStats.SnapshotCount}}</span>
</div>
</div>
{{end}}
{{end}}
<div class="repo-info-rows" style="margin-top:0.5rem">
<div class="repo-info-row">
<span class="repo-label">Adatbázis mentések:</span>
<span class="repo-value">{{if .Backup.DumpFiles}}{{len .Backup.DumpFiles}} dump fájl{{if gt .DBDumpTotalBytes 0}} — {{fmtBytes .DBDumpTotalBytes}}{{end}}{{else}}Nincs dump fájl{{end}}</span>
@@ -424,31 +457,59 @@
</div>
{{end}}
</div>
</div>
<!-- Tier 2: Cross-drive backup destinations -->
{{if .Tier2Dests}}
<div class="repo-tier">
<h4 class="repo-tier-title">2. mentés — Másodlagos másolat</h4>
{{range .Tier2Dests}}
<div class="repo-info-rows">
<div class="repo-info-row">
<span class="repo-label">Cél:</span>
<span class="repo-value mono">{{index . "Path"}}{{if index . "Label"}} <span class="relative-time">({{index . "Label"}})</span>{{end}}</span>
<!-- Tier 2: Másodlagos másolat (collapsible, collapsed by default) -->
<div class="details-tier">
<div class="details-tier-header" onclick="toggleTier(this)">
<span class="expand-icon"></span>
<h4 class="repo-tier-title">2. szint — Másodlagos másolat</h4>
</div>
<div class="details-tier-body" style="display:none">
{{if .Tier2DriveGroups}}
{{range .Tier2DriveGroups}}
<div class="drive-detail-card">
<div class="drive-detail-header">{{.DestLabel}} <span class="relative-time mono">({{.DestPath}})</span></div>
{{if .ResticItems}}
<div class="method-group">
<div class="method-group-label">Restic:</div>
{{range .ResticItems}}
<div class="repo-info-row">
<span class="repo-label">Módszer:</span>
<span class="repo-value">{{index . "Method"}}</span>
<span class="repo-label">{{.DisplayName}}</span>
<span class="repo-value">{{if .SizeHuman}}{{.SizeHuman}}{{else}}—{{end}}</span>
</div>
{{if index . "SizeHuman"}}
{{end}}
</div>
{{end}}
{{if .RsyncItems}}
<div class="method-group">
<div class="method-group-label">Rsync:</div>
{{range .RsyncItems}}
<div class="repo-info-row">
<span class="repo-label">Méret:</span>
<span class="repo-value">{{index . "SizeHuman"}}</span>
<span class="repo-label">{{.DisplayName}}</span>
<span class="repo-value">{{if .SizeHuman}}{{.SizeHuman}}{{else}}—{{end}}</span>
</div>
{{end}}
</div>
{{end}}
</div>
{{end}}
{{else}}
<div class="tier-empty-state">Nincs 2. szintű mentés konfigurálva.</div>
{{end}}
</div>
</div>
<!-- Tier 3: Távoli mentés (collapsible, collapsed by default, placeholder) -->
<div class="details-tier">
<div class="details-tier-header" onclick="toggleTier(this)">
<span class="expand-icon"></span>
<h4 class="repo-tier-title" style="opacity:.6">3. szint — Távoli mentés (offsite)</h4>
</div>
<div class="details-tier-body" style="display:none">
<div class="tier-empty-state">B2 / S3 / SFTP — hamarosan elérhető</div>
</div>
</div>
</div>
<!-- Section 7: Restore -->
@@ -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 = '<option value="">— Válasszon —</option>';
if (data.ok && data.data && data.data.length > 0) {
data.data.forEach(function(s) {
// 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);
snapSel.appendChild(o);
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 = '<option value="">— Nincs elérhető mentés —</option>';
noSnaps.style.display = 'block';
@@ -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;