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:
@@ -1,5 +1,22 @@
|
|||||||
## Changelog
|
## 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)
|
### What was just completed (2026-02-18 session 50)
|
||||||
- **v0.15.0 — Attach Existing Drive (bind mount wizard):**
|
- **v0.15.0 — Attach Existing Drive (bind mount wizard):**
|
||||||
|
|
||||||
|
|||||||
@@ -451,17 +451,25 @@ func (r *Router) backupSnapshots(w http.ResponseWriter, req *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshots, err := r.backupMgr.ListSnapshots(50)
|
snapshots, err := r.backupMgr.ListAllSnapshots(50)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// All snapshots contain the stacks dir + DB dumps, so they're useful for
|
// Enrich snapshots with drive labels from storage paths
|
||||||
// any app (config + DB restore). Apps with HDD data get user data restored
|
if r.sett != nil {
|
||||||
// too — but only from snapshots that include those paths (post-v0.12.7).
|
storagePaths := r.sett.GetStoragePaths()
|
||||||
// We don't filter here because older snapshots still allow config+DB restore,
|
for i := range snapshots {
|
||||||
// and the RestoreApp function extracts whatever paths are available.
|
repoPath := snapshots[i].RepoPath
|
||||||
|
for _, sp := range storagePaths {
|
||||||
|
if strings.HasPrefix(repoPath, sp.Path) {
|
||||||
|
snapshots[i].DriveLabel = sp.Label
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if snapshots == nil {
|
if snapshots == nil {
|
||||||
snapshots = []backup.SnapshotInfo{}
|
snapshots = []backup.SnapshotInfo{}
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ type SnapshotRecord struct {
|
|||||||
HasStats bool `json:"has_stats"` // false for historical entries loaded from restic
|
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.
|
// CrossDriveSummaryItem holds display data for one app's cross-drive backup.
|
||||||
type CrossDriveSummaryItem struct {
|
type CrossDriveSummaryItem struct {
|
||||||
StackName string
|
StackName string
|
||||||
@@ -81,9 +90,10 @@ type FullBackupStatus struct {
|
|||||||
DiscoveredDBs []DiscoveredDB
|
DiscoveredDBs []DiscoveredDB
|
||||||
|
|
||||||
// Restic
|
// Restic
|
||||||
LastBackup *BackupStatus
|
LastBackup *BackupStatus
|
||||||
SnapshotHistory []SnapshotRecord
|
SnapshotHistory []SnapshotRecord
|
||||||
RepoStats *RepoStats
|
RepoStats *RepoStats
|
||||||
|
PerDriveRepoStats []DriveRepoInfo // per-drive Tier 1 restic stats
|
||||||
|
|
||||||
// Schedule
|
// Schedule
|
||||||
DBDumpSchedule string
|
DBDumpSchedule string
|
||||||
@@ -565,6 +575,66 @@ func (m *Manager) ListSnapshots(limit int) ([]SnapshotInfo, error) {
|
|||||||
return allSnapshots, nil
|
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.
|
// SetStackProvider sets the stack data provider for app data discovery.
|
||||||
// C3: Write is protected by mutex since stackProvider is read by concurrent goroutines.
|
// C3: Write is protected by mutex since stackProvider is read by concurrent goroutines.
|
||||||
func (m *Manager) SetStackProvider(provider StackDataProvider) {
|
func (m *Manager) SetStackProvider(provider StackDataProvider) {
|
||||||
@@ -632,6 +702,29 @@ func (m *Manager) DumpStackDB(ctx context.Context, stackName string) error {
|
|||||||
return nil
|
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.
|
// aggregateRepoStats combines stats from all primary restic repos.
|
||||||
func (m *Manager) aggregateRepoStats() *RepoStats {
|
func (m *Manager) aggregateRepoStats() *RepoStats {
|
||||||
drives := m.activeDrives()
|
drives := m.activeDrives()
|
||||||
@@ -758,6 +851,7 @@ func (m *Manager) RefreshCache(nextDBDump, nextBackup time.Time) {
|
|||||||
|
|
||||||
// Expensive calls (outside lock)
|
// Expensive calls (outside lock)
|
||||||
status.RepoStats = m.aggregateRepoStats()
|
status.RepoStats = m.aggregateRepoStats()
|
||||||
|
status.PerDriveRepoStats = m.perDriveRepoStats()
|
||||||
|
|
||||||
// Scan dump files from per-drive per-stack paths
|
// Scan dump files from per-drive per-stack paths
|
||||||
files := m.listAllDumpFiles()
|
files := m.listAllDumpFiles()
|
||||||
@@ -831,6 +925,8 @@ func (m *Manager) GetFullStatus(nextDBDump, nextBackup time.Time) *FullBackupSta
|
|||||||
status := *m.cachedStatus
|
status := *m.cachedStatus
|
||||||
status.AppDataInfo = make([]AppBackupInfo, len(m.cachedStatus.AppDataInfo))
|
status.AppDataInfo = make([]AppBackupInfo, len(m.cachedStatus.AppDataInfo))
|
||||||
copy(status.AppDataInfo, 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;
|
// These three slices are assembled by the handler from AppDataInfo + settings;
|
||||||
// they must always start empty so the handler builds them fresh.
|
// they must always start empty so the handler builds them fresh.
|
||||||
status.CrossDriveSummary = nil
|
status.CrossDriveSummary = nil
|
||||||
|
|||||||
@@ -36,11 +36,13 @@ type SnapshotResult struct {
|
|||||||
|
|
||||||
// SnapshotInfo holds information about a restic snapshot.
|
// SnapshotInfo holds information about a restic snapshot.
|
||||||
type SnapshotInfo struct {
|
type SnapshotInfo struct {
|
||||||
ID string `json:"short_id"`
|
ID string `json:"short_id"`
|
||||||
Time time.Time `json:"time"`
|
Time time.Time `json:"time"`
|
||||||
Paths []string `json:"paths"`
|
Paths []string `json:"paths"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
RepoPath string `json:"-"` // set by caller for multi-repo aggregation
|
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.
|
// RepoStats holds repository statistics.
|
||||||
|
|||||||
@@ -525,33 +525,59 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
data["ResticPassword"] = pw
|
data["ResticPassword"] = pw
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tároló section: DB dump total size
|
// Részletek section: DB dump total size
|
||||||
var dbDumpTotalBytes int64
|
var dbDumpTotalBytes int64
|
||||||
for _, f := range fullStatus.DumpFiles {
|
for _, f := range fullStatus.DumpFiles {
|
||||||
dbDumpTotalBytes += f.Size
|
dbDumpTotalBytes += f.Size
|
||||||
}
|
}
|
||||||
data["DBDumpTotalBytes"] = dbDumpTotalBytes
|
data["DBDumpTotalBytes"] = dbDumpTotalBytes
|
||||||
|
|
||||||
// Tároló section: deduplicated Tier 2 destination list
|
// Részletek section: enrich per-drive repo stats with storage labels
|
||||||
tier2DestMap := make(map[string]map[string]string)
|
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 {
|
for _, item := range fullStatus.CrossDriveSummary {
|
||||||
if item.DestPath == "" {
|
if item.DestPath == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, exists := tier2DestMap[item.DestPath]; !exists {
|
grp, exists := tier2GroupMap[item.DestPath]
|
||||||
tier2DestMap[item.DestPath] = map[string]string{
|
if !exists {
|
||||||
"Path": item.DestPath,
|
grp = &Tier2DriveGroup{
|
||||||
"Label": item.DestLabel,
|
DestPath: item.DestPath,
|
||||||
"Method": item.MethodLabel,
|
DestLabel: item.DestLabel,
|
||||||
"SizeHuman": item.SizeHuman,
|
|
||||||
}
|
}
|
||||||
|
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
|
var tier2Groups []Tier2DriveGroup
|
||||||
for _, d := range tier2DestMap {
|
for _, grp := range tier2GroupMap {
|
||||||
tier2DestList = append(tier2DestList, d)
|
tier2Groups = append(tier2Groups, *grp)
|
||||||
}
|
}
|
||||||
data["Tier2Dests"] = tier2DestList
|
data["Tier2DriveGroups"] = tier2Groups
|
||||||
} else {
|
} else {
|
||||||
data["Backup"] = nil
|
data["Backup"] = nil
|
||||||
}
|
}
|
||||||
@@ -559,6 +585,14 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.render(w, "backups", data)
|
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.
|
// AppBackupRow holds per-tier backup information for one app on the backup page.
|
||||||
type AppBackupRow struct {
|
type AppBackupRow struct {
|
||||||
StackName string
|
StackName string
|
||||||
|
|||||||
@@ -373,82 +373,143 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Section 6: Repository -->
|
<!-- Section 6: Részletek (Details) -->
|
||||||
<div class="repo-card">
|
<div class="repo-card">
|
||||||
<h3>Tároló</h3>
|
<h3>Részletek</h3>
|
||||||
|
|
||||||
<!-- Tier 1: Local restic backup (per-drive) -->
|
<!-- Tier 1: Helyi mentés (collapsible, open by default) -->
|
||||||
<div class="repo-tier">
|
<div class="details-tier">
|
||||||
<h4 class="repo-tier-title">1. mentés — Helyi mentés (restic)</h4>
|
<div class="details-tier-header" onclick="toggleTier(this)">
|
||||||
<div class="repo-info-rows">
|
<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}}
|
{{if .Backup.RepoStats}}
|
||||||
<div class="repo-info-row">
|
<div class="repo-info-rows">
|
||||||
<span class="repo-label">Méret:</span>
|
<div class="repo-info-row">
|
||||||
<span class="repo-value">{{.Backup.RepoStats.TotalSize}}</span>
|
<span class="repo-label">Méret:</span>
|
||||||
</div>
|
<span class="repo-value">{{.Backup.RepoStats.TotalSize}}</span>
|
||||||
<div class="repo-info-row">
|
</div>
|
||||||
<span class="repo-label">Pillanatképek:</span>
|
<div class="repo-info-row">
|
||||||
<span class="repo-value">{{.Backup.RepoStats.SnapshotCount}}</span>
|
<span class="repo-label">Pillanatképek:</span>
|
||||||
|
<span class="repo-value">{{.Backup.RepoStats.SnapshotCount}}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
<div class="repo-info-row">
|
{{end}}
|
||||||
<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>
|
<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>
|
||||||
|
</div>
|
||||||
|
<div class="repo-info-row">
|
||||||
|
<span class="repo-label">Integritás:</span>
|
||||||
|
<span class="repo-value">
|
||||||
|
{{if .Backup.LastCheckTime.IsZero}}
|
||||||
|
<span class="relative-time">Még nem ellenőrzött</span>
|
||||||
|
{{else if .Backup.LastCheckOK}}
|
||||||
|
<span class="backup-status-ok">Rendben</span> <span class="relative-time">({{fmtTime .Backup.LastCheckTime}})</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="backup-status-fail">Hiba</span> <span class="relative-time">({{fmtTime .Backup.LastCheckTime}})</span>
|
||||||
|
{{end}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="repo-info-row">
|
|
||||||
<span class="repo-label">Integritás:</span>
|
<!-- Encryption key -->
|
||||||
<span class="repo-value">
|
{{if $.ResticPassword}}
|
||||||
{{if .Backup.LastCheckTime.IsZero}}
|
<div class="repo-encryption">
|
||||||
<span class="relative-time">Még nem ellenőrzött</span>
|
<span class="repo-label">Titkosítási kulcs:</span>
|
||||||
{{else if .Backup.LastCheckOK}}
|
<div class="repo-encryption-row">
|
||||||
<span class="backup-status-ok">Rendben</span> <span class="relative-time">({{fmtTime .Backup.LastCheckTime}})</span>
|
<input type="password" id="restic-pw" class="restic-pw-field mono" value="{{$.ResticPassword}}" readonly>
|
||||||
{{else}}
|
<button type="button" class="btn btn-sm" onclick="toggleResticPw()">Megjelenítés</button>
|
||||||
<span class="backup-status-fail">Hiba</span> <span class="relative-time">({{fmtTime .Backup.LastCheckTime}})</span>
|
<button type="button" class="btn btn-sm" onclick="copyResticPw()">Másolás</button>
|
||||||
|
</div>
|
||||||
|
<div class="repo-encryption-warn">
|
||||||
|
Mentse el biztonságos helyre! A kulcs nélkül a biztonsági mentések NEM állíthatók vissza.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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">{{.DisplayName}}</span>
|
||||||
|
<span class="repo-value">{{if .SizeHuman}}{{.SizeHuman}}{{else}}—{{end}}</span>
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
{{end}}
|
||||||
</div>
|
{{if .RsyncItems}}
|
||||||
|
<div class="method-group">
|
||||||
<!-- Encryption key -->
|
<div class="method-group-label">Rsync:</div>
|
||||||
{{if $.ResticPassword}}
|
{{range .RsyncItems}}
|
||||||
<div class="repo-encryption">
|
<div class="repo-info-row">
|
||||||
<span class="repo-label">Titkosítási kulcs:</span>
|
<span class="repo-label">{{.DisplayName}}</span>
|
||||||
<div class="repo-encryption-row">
|
<span class="repo-value">{{if .SizeHuman}}{{.SizeHuman}}{{else}}—{{end}}</span>
|
||||||
<input type="password" id="restic-pw" class="restic-pw-field mono" value="{{$.ResticPassword}}" readonly>
|
</div>
|
||||||
<button type="button" class="btn btn-sm" onclick="toggleResticPw()">Megjelenítés</button>
|
{{end}}
|
||||||
<button type="button" class="btn btn-sm" onclick="copyResticPw()">Másolás</button>
|
</div>
|
||||||
</div>
|
{{end}}
|
||||||
<div class="repo-encryption-warn">
|
|
||||||
Mentse el biztonságos helyre! A kulcs nélkül a biztonsági mentések NEM állíthatók vissza.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
<div class="repo-info-row">
|
|
||||||
<span class="repo-label">Módszer:</span>
|
|
||||||
<span class="repo-value">{{index . "Method"}}</span>
|
|
||||||
</div>
|
|
||||||
{{if index . "SizeHuman"}}
|
|
||||||
<div class="repo-info-row">
|
|
||||||
<span class="repo-label">Méret:</span>
|
|
||||||
<span class="repo-value">{{index . "SizeHuman"}}</span>
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{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>
|
||||||
{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Section 7: Restore -->
|
<!-- 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) {
|
function triggerCrossDriveBackup(stackName, btn) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Fut...';
|
btn.textContent = 'Fut...';
|
||||||
@@ -607,9 +680,16 @@ var huDays = ['vasárnap', 'hétfő', 'kedd', 'szerda', 'csütörtök', 'péntek
|
|||||||
function formatSnapshot(s) {
|
function formatSnapshot(s) {
|
||||||
var t = new Date(s.time);
|
var t = new Date(s.time);
|
||||||
var pad = function(n) { return n < 10 ? '0' + n : '' + n; };
|
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()) +
|
' ' + huDays[t.getDay()] + ' ' + pad(t.getHours()) + ':' + pad(t.getMinutes()) +
|
||||||
' (' + s.short_id + ')';
|
' (' + 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() {
|
function onRestoreAppChange() {
|
||||||
@@ -653,12 +733,32 @@ function onRestoreAppChange() {
|
|||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
snapSel.innerHTML = '<option value="">— Válasszon —</option>';
|
snapSel.innerHTML = '<option value="">— Válasszon —</option>';
|
||||||
if (data.ok && data.data && data.data.length > 0) {
|
if (data.ok && data.data && data.data.length > 0) {
|
||||||
data.data.forEach(function(s) {
|
// Group by tier
|
||||||
var o = document.createElement('option');
|
var tier1 = data.data.filter(function(s) { return s.tier !== 2; });
|
||||||
o.value = s.short_id;
|
var tier2 = data.data.filter(function(s) { return s.tier === 2; });
|
||||||
o.textContent = formatSnapshot(s);
|
|
||||||
snapSel.appendChild(o);
|
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 {
|
} else {
|
||||||
snapSel.innerHTML = '<option value="">— Nincs elérhető mentés —</option>';
|
snapSel.innerHTML = '<option value="">— Nincs elérhető mentés —</option>';
|
||||||
noSnaps.style.display = 'block';
|
noSnaps.style.display = 'block';
|
||||||
|
|||||||
@@ -1600,6 +1600,65 @@ a.stat-card:hover {
|
|||||||
gap: .15rem;
|
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 {
|
.relative-time {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: .8rem;
|
font-size: .8rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user