diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index a9e2aab..96f3abe 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -81,6 +81,7 @@ func main() { var backupMgr *backup.Manager if cfg.Backup.Enabled { backupMgr = backup.NewManager(cfg, pinger, logger) + go backupMgr.LoadSnapshotHistory() } // --- Initialize scheduler --- @@ -128,7 +129,7 @@ func main() { apiRouter := api.NewRouter(cfg, stackMgr, syncer, cpuCollector, backupMgr, logger) // --- Initialize web server --- - webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, logger, Version) + webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, sched, logger, Version) // --- Build HTTP mux --- mux := http.NewServeMux() diff --git a/controller/internal/backup/backup.go b/controller/internal/backup/backup.go index 513f598..4bf9ce8 100644 --- a/controller/internal/backup/backup.go +++ b/controller/internal/backup/backup.go @@ -19,10 +19,58 @@ type Manager struct { logger *log.Logger pinger *monitor.Pinger - mu sync.Mutex - lastDBDump *DBDumpStatus - lastBackup *BackupStatus - running bool + mu sync.Mutex + lastDBDump *DBDumpStatus + lastBackup *BackupStatus + running bool + snapshotHistory []SnapshotRecord // ring buffer, last 20 entries + lastCheckTime time.Time + lastCheckOK bool +} + +// SnapshotRecord combines restic snapshot metadata with our run stats. +type SnapshotRecord struct { + SnapshotID string `json:"snapshot_id"` + Time time.Time `json:"time"` + FilesNew int `json:"files_new"` + FilesChanged int `json:"files_changed"` + DataAdded string `json:"data_added"` + Duration time.Duration `json:"duration"` + Success bool `json:"success"` + HasStats bool `json:"has_stats"` // false for historical entries loaded from restic +} + +// FullBackupStatus contains everything the backup page needs. +type FullBackupStatus struct { + Enabled bool + Running bool + + // DB Dumps + LastDBDump *DBDumpStatus + DumpFiles []DumpFileInfo + DiscoveredDBs []DiscoveredDB + + // Restic + LastBackup *BackupStatus + SnapshotHistory []SnapshotRecord + RepoStats *RepoStats + + // Schedule + DBDumpSchedule string + ResticSchedule string + PruneSchedule string + NextDBDump time.Time + NextBackup time.Time + Retention config.RetentionConfig + + // Repository health + RepoPath string + BackupPaths []string + LastCheckTime time.Time + LastCheckOK bool + + // Remote (placeholder) + RemoteEnabled bool } // DBDumpStatus holds the last DB dump result. @@ -162,9 +210,14 @@ func (m *Manager) RunBackup(ctx context.Context) error { if err := m.restic.Prune(m.cfg.Backup.Retention); err != nil { m.logger.Printf("[WARN] Restic prune failed: %v", err) } - if err := m.restic.Check(); err != nil { - m.logger.Printf("[WARN] Restic check failed: %v", err) + checkErr := m.restic.Check() + if checkErr != nil { + m.logger.Printf("[WARN] Restic check failed: %v", checkErr) } + m.mu.Lock() + m.lastCheckTime = time.Now() + m.lastCheckOK = checkErr == nil + m.mu.Unlock() } // Get stats @@ -179,6 +232,17 @@ func (m *Manager) RunBackup(ctx context.Context) error { Duration: duration, RepoStats: stats, } + // Append to snapshot history + m.appendSnapshotRecord(SnapshotRecord{ + SnapshotID: result.SnapshotID, + Time: time.Now(), + FilesNew: result.FilesNew, + FilesChanged: result.FilesChanged, + DataAdded: result.DataAdded, + Duration: duration, + Success: true, + HasStats: true, + }) m.mu.Unlock() body := fmt.Sprintf("Backup OK\nSnapshot: %s\nNew files: %d, Changed: %d\nData added: %s\nDuration: %s", @@ -254,6 +318,96 @@ func shouldPrune(schedule string) bool { } } +// appendSnapshotRecord adds a record to the ring buffer (max 20). Caller must hold m.mu. +func (m *Manager) appendSnapshotRecord(rec SnapshotRecord) { + m.snapshotHistory = append(m.snapshotHistory, rec) + if len(m.snapshotHistory) > 20 { + m.snapshotHistory = m.snapshotHistory[len(m.snapshotHistory)-20:] + } +} + +// LoadSnapshotHistory populates the snapshot history from restic on startup. +func (m *Manager) LoadSnapshotHistory() { + snapshots, err := m.restic.ListSnapshots(20) + if err != nil { + m.logger.Printf("[WARN] Could not load snapshot history: %v", err) + return + } + + m.mu.Lock() + defer m.mu.Unlock() + + for _, s := range snapshots { + m.snapshotHistory = append(m.snapshotHistory, SnapshotRecord{ + SnapshotID: s.ID, + Time: s.Time, + HasStats: false, // historical — no delta stats available + Success: true, + }) + } + if len(m.snapshotHistory) > 20 { + m.snapshotHistory = m.snapshotHistory[len(m.snapshotHistory)-20:] + } + m.logger.Printf("[INFO] Loaded %d historical snapshots", len(m.snapshotHistory)) +} + +// GetFullStatus returns everything the backup page needs. +func (m *Manager) GetFullStatus(nextDBDump, nextBackup time.Time) *FullBackupStatus { + m.mu.Lock() + status := &FullBackupStatus{ + Enabled: m.cfg.Backup.Enabled, + Running: m.running, + LastDBDump: m.lastDBDump, + LastBackup: m.lastBackup, + + DBDumpSchedule: m.cfg.Backup.DBDumpSchedule, + ResticSchedule: m.cfg.Backup.ResticSchedule, + PruneSchedule: m.cfg.Backup.PruneSchedule, + NextDBDump: nextDBDump, + NextBackup: nextBackup, + Retention: m.cfg.Backup.Retention, + + RepoPath: m.cfg.Backup.ResticRepo, + LastCheckTime: m.lastCheckTime, + LastCheckOK: m.lastCheckOK, + } + // Copy snapshot history + status.SnapshotHistory = make([]SnapshotRecord, len(m.snapshotHistory)) + copy(status.SnapshotHistory, m.snapshotHistory) + m.mu.Unlock() + + // Reverse so newest first + for i, j := 0, len(status.SnapshotHistory)-1; i < j; i, j = i+1, j-1 { + status.SnapshotHistory[i], status.SnapshotHistory[j] = status.SnapshotHistory[j], status.SnapshotHistory[i] + } + + // Backup paths + status.BackupPaths = []string{ + m.cfg.Paths.StacksDir, + m.cfg.Paths.DBDumpDir, + "/opt/docker/felhom-controller/controller.yaml", + } + + // Get repo stats (non-locked) + if stats, err := m.restic.Stats(); err == nil { + status.RepoStats = stats + } + + // List dump files from disk + if files, err := ListDumpFiles(m.cfg.Paths.DBDumpDir); err == nil { + status.DumpFiles = files + } + + // Discover running DBs + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if dbs, err := DiscoverDatabases(ctx, m.logger); err == nil { + status.DiscoveredDBs = dbs + } + + return status +} + func dbNames(dbs []DiscoveredDB) string { var names []string for _, db := range dbs { diff --git a/controller/internal/backup/dbdump.go b/controller/internal/backup/dbdump.go index ff4440a..5594731 100644 --- a/controller/internal/backup/dbdump.go +++ b/controller/internal/backup/dbdump.go @@ -31,11 +31,31 @@ type DiscoveredDB struct { // DumpResult holds the outcome of a single database dump. type DumpResult struct { - DB DiscoveredDB - FilePath string - Size int64 - Duration time.Duration - Error error + DB DiscoveredDB + FilePath string + Size int64 + Duration time.Duration + Error error + Validation DumpValidation +} + +// DumpValidation holds the result of a dump file structural check. +type DumpValidation struct { + Valid bool + TableCount int + Error string + FileSize int64 + ModTime time.Time +} + +// DumpFileInfo holds info about a dump file on disk. +type DumpFileInfo struct { + FileName string + StackName string + DBType DBType + Size int64 + ModTime time.Time + Validation DumpValidation } // DiscoverDatabases finds running database containers via docker ps. @@ -200,12 +220,125 @@ func DumpOne(ctx context.Context, db DiscoveredDB, dumpDir string, logger *log.L result.Size = stat.Size() result.Duration = time.Since(start) - logger.Printf("[INFO] DB dump: %s → %s (%s, %s)", db.ContainerName, filename, - formatBytes(stat.Size()), result.Duration.Round(time.Millisecond)) + // Run validation on the dump file + result.Validation = ValidateDump(finalPath, db.DBType) + + logger.Printf("[INFO] DB dump: %s → %s (%s, %s, %d tables)", db.ContainerName, filename, + formatBytes(stat.Size()), result.Duration.Round(time.Millisecond), result.Validation.TableCount) return result } +// ValidateDump checks a SQL dump file for basic structural integrity. +func ValidateDump(filePath string, dbType DBType) DumpValidation { + stat, err := os.Stat(filePath) + if err != nil { + return DumpValidation{Error: fmt.Sprintf("stat failed: %v", err)} + } + + v := DumpValidation{ + FileSize: stat.Size(), + ModTime: stat.ModTime(), + } + + if stat.Size() < 100 { + v.Error = "dump file too small (< 100 bytes)" + return v + } + + data, err := os.ReadFile(filePath) + if err != nil { + v.Error = fmt.Sprintf("read failed: %v", err) + return v + } + + content := string(data) + + // Count CREATE TABLE statements + tableCount := 0 + for _, line := range strings.Split(content, "\n") { + upper := strings.ToUpper(strings.TrimSpace(line)) + if strings.HasPrefix(upper, "CREATE TABLE") { + tableCount++ + } + } + v.TableCount = tableCount + + // Basic header check + switch dbType { + case DBTypePostgres: + if !strings.HasPrefix(content, "--") { + v.Error = "PostgreSQL dump missing comment header" + return v + } + case DBTypeMariaDB: + if !strings.HasPrefix(content, "-- ") { + v.Error = "MariaDB dump missing comment header" + return v + } + } + + if tableCount == 0 { + v.Error = "no CREATE TABLE statements found" + return v + } + + v.Valid = true + return v +} + +// ListDumpFiles returns info about SQL dump files on disk. +func ListDumpFiles(dumpDir string) ([]DumpFileInfo, error) { + entries, err := os.ReadDir(dumpDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("reading dump dir: %w", err) + } + + var files []DumpFileInfo + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") { + continue + } + if strings.HasSuffix(e.Name(), ".tmp") { + continue + } + + info, err := e.Info() + if err != nil { + continue + } + + f := DumpFileInfo{ + FileName: e.Name(), + Size: info.Size(), + ModTime: info.ModTime(), + } + + // Parse stack name and DB type from filename: "paperless-ngx-postgres.sql" + base := strings.TrimSuffix(e.Name(), ".sql") + if strings.HasSuffix(base, "-postgres") { + f.StackName = strings.TrimSuffix(base, "-postgres") + f.DBType = DBTypePostgres + } else if strings.HasSuffix(base, "-mariadb") { + f.StackName = strings.TrimSuffix(base, "-mariadb") + f.DBType = DBTypeMariaDB + } else { + f.StackName = base + } + + // Run validation on the file + fullPath := filepath.Join(dumpDir, e.Name()) + f.Validation = ValidateDump(fullPath, f.DBType) + + files = append(files, f) + } + + return files, nil +} + func populateDBEnv(ctx context.Context, db *DiscoveredDB) error { cmd := exec.CommandContext(ctx, "docker", "inspect", db.ContainerID, "--format", "{{range .Config.Env}}{{println .}}{{end}}") diff --git a/controller/internal/backup/restic.go b/controller/internal/backup/restic.go index 429bd71..debe603 100644 --- a/controller/internal/backup/restic.go +++ b/controller/internal/backup/restic.go @@ -216,6 +216,34 @@ func (r *ResticManager) Check() error { return nil } +// ListSnapshots returns all snapshots, newest first, limited to N entries. +func (r *ResticManager) ListSnapshots(limit int) ([]SnapshotInfo, error) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + cmd := r.command(ctx, "snapshots", "--json") + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("restic snapshots failed: %v", err) + } + + var snapshots []SnapshotInfo + if err := json.Unmarshal(out, &snapshots); err != nil { + return nil, fmt.Errorf("parsing snapshot JSON: %v", err) + } + + // Reverse for newest first + for i, j := 0, len(snapshots)-1; i < j; i, j = i+1, j-1 { + snapshots[i], snapshots[j] = snapshots[j], snapshots[i] + } + + if limit > 0 && len(snapshots) > limit { + snapshots = snapshots[:limit] + } + + return snapshots, nil +} + // LatestSnapshot returns the most recent snapshot info. func (r *ResticManager) LatestSnapshot() (*SnapshotInfo, error) { ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) diff --git a/controller/internal/scheduler/scheduler.go b/controller/internal/scheduler/scheduler.go index e722777..2d8785a 100644 --- a/controller/internal/scheduler/scheduler.go +++ b/controller/internal/scheduler/scheduler.go @@ -225,6 +225,11 @@ func parseDailyTime(timeStr string) (int, int, error) { return hour, min, nil } +// NextDailyRun is the exported version of nextDailyRun for external callers. +func NextDailyRun(timeStr string) time.Time { + return nextDailyRun(timeStr) +} + // nextDailyRun calculates the next occurrence of the daily schedule in Europe/Budapest timezone. func nextDailyRun(timeStr string) time.Time { hour, min, err := parseDailyTime(timeStr) diff --git a/controller/internal/web/funcmap.go b/controller/internal/web/funcmap.go index a554515..bba0047 100644 --- a/controller/internal/web/funcmap.go +++ b/controller/internal/web/funcmap.go @@ -3,7 +3,10 @@ package web import ( "fmt" "html/template" + "strings" + "time" + "gitea.dooplex.hu/admin/felhom-controller/internal/backup" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" ) @@ -157,5 +160,145 @@ func (s *Server) templateFuncMap() template.FuncMap { return "available" } }, + "timeAgo": func(t time.Time) string { + if t.IsZero() { + return "–" + } + loc, _ := time.LoadLocation("Europe/Budapest") + if loc == nil { + loc = time.UTC + } + now := time.Now().In(loc) + d := now.Sub(t.In(loc)) + switch { + case d < time.Minute: + return "most" + case d < time.Hour: + return fmt.Sprintf("%d perce", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%d órája", int(d.Hours())) + case d < 48*time.Hour: + return "tegnap" + default: + return fmt.Sprintf("%d napja", int(d.Hours()/24)) + } + }, + "fmtTime": func(t time.Time) string { + if t.IsZero() { + return "–" + } + loc, _ := time.LoadLocation("Europe/Budapest") + if loc == nil { + loc = time.UTC + } + return t.In(loc).Format("2006-01-02 15:04") + }, + "fmtTimeShort": func(t time.Time) string { + if t.IsZero() { + return "–" + } + loc, _ := time.LoadLocation("Europe/Budapest") + if loc == nil { + loc = time.UTC + } + lt := t.In(loc) + now := time.Now().In(loc) + if lt.Year() == now.Year() && lt.YearDay() == now.YearDay() { + return lt.Format("15:04") + } + return lt.Format("01-02 15:04") + }, + "dbTypeLabel": func(t backup.DBType) string { + switch t { + case backup.DBTypePostgres: + return "PostgreSQL" + case backup.DBTypeMariaDB: + return "MariaDB" + default: + return string(t) + } + }, + "nextRunLabel": func(t time.Time) string { + if t.IsZero() { + return "–" + } + loc, _ := time.LoadLocation("Europe/Budapest") + if loc == nil { + loc = time.UTC + } + lt := t.In(loc) + now := time.Now().In(loc) + timeStr := lt.Format("15:04") + if lt.Year() == now.Year() && lt.YearDay() == now.YearDay() { + return "ma " + timeStr + } + if lt.Year() == now.Year() && lt.YearDay() == now.YearDay()+1 { + return "holnap " + timeStr + } + return lt.Format("2006-01-02") + " " + timeStr + }, + "pruneLabel": func(s string) string { + switch strings.ToLower(s) { + case "weekly": + return "vasárnap" + case "daily": + return "naponta" + case "sunday": + return "vasárnap" + default: + return s + } + }, + "nextPruneLabel": func(schedule string) string { + loc, _ := time.LoadLocation("Europe/Budapest") + if loc == nil { + loc = time.UTC + } + now := time.Now().In(loc) + var next time.Time + switch strings.ToLower(schedule) { + case "daily": + next = now.Add(24 * time.Hour) + default: // weekly/sunday + daysUntilSunday := (7 - int(now.Weekday())) % 7 + if daysUntilSunday == 0 && now.Hour() >= 4 { + daysUntilSunday = 7 + } + next = now.AddDate(0, 0, daysUntilSunday) + } + return next.Format("2006-01-02") + }, + "fmtDuration": func(d time.Duration) string { + if d < time.Second { + return "< 1s" + } + if d < time.Minute { + return fmt.Sprintf("%ds", int(d.Seconds())) + } + return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60) + }, + "fmtBytes": func(b int64) string { + const ( + kb = 1024 + mb = 1024 * kb + gb = 1024 * mb + ) + switch { + case b >= int64(gb): + return fmt.Sprintf("%.1f GB", float64(b)/float64(gb)) + case b >= int64(mb): + return fmt.Sprintf("%.1f MB", float64(b)/float64(mb)) + case b >= int64(kb): + return fmt.Sprintf("%.1f KB", float64(b)/float64(kb)) + default: + return fmt.Sprintf("%d B", b) + } + }, + "shortID": func(id string) string { + if len(id) > 8 { + return id[:8] + } + return id + }, } } diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 7c1d347..89a50cb 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" + "gitea.dooplex.hu/admin/felhom-controller/internal/scheduler" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" "gitea.dooplex.hu/admin/felhom-controller/internal/system" ) @@ -181,3 +182,18 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, _ *http.Request, slug s s.render(w, "app_info", data) } + +func (s *Server) backupsHandler(w http.ResponseWriter, _ *http.Request) { + data := s.baseData("backups", "Biztonsági mentés") + + if s.backupMgr != nil { + nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule) + nextBackup := scheduler.NextDailyRun(s.cfg.Backup.ResticSchedule) + fullStatus := s.backupMgr.GetFullStatus(nextDBDump, nextBackup) + data["Backup"] = fullStatus + } else { + data["Backup"] = nil + } + + s.render(w, "backups", data) +} diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 7e513e9..b1595df 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -12,6 +12,7 @@ import ( "gitea.dooplex.hu/admin/felhom-controller/internal/backup" "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/scheduler" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" "gitea.dooplex.hu/admin/felhom-controller/internal/system" ) @@ -21,6 +22,7 @@ type Server struct { stackMgr *stacks.Manager cpuCollector *system.CPUCollector backupMgr *backup.Manager + scheduler *scheduler.Scheduler logger *log.Logger version string tmpl *template.Template @@ -29,12 +31,13 @@ type Server struct { sessionsMu sync.RWMutex } -func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, logger *log.Logger, version string) *Server { +func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, sched *scheduler.Scheduler, logger *log.Logger, version string) *Server { s := &Server{ cfg: cfg, stackMgr: stackMgr, cpuCollector: cpuCollector, backupMgr: backupMgr, + scheduler: sched, logger: logger, version: version, sessions: make(map[string]*session), @@ -59,6 +62,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.dashboardHandler(w, r) case path == "/stacks": s.stacksHandler(w, r) + case path == "/backups": + s.backupsHandler(w, r) case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/logs"): name := strings.TrimPrefix(path, "/stacks/") name = strings.TrimSuffix(name, "/logs") diff --git a/controller/internal/web/templates/backups.html b/controller/internal/web/templates/backups.html new file mode 100644 index 0000000..c6a9030 --- /dev/null +++ b/controller/internal/web/templates/backups.html @@ -0,0 +1,311 @@ +{{define "backups"}} +{{template "layout_start" .}} + +
A biztonsági mentés funkció nem aktív.
+ Kérjük, vegye fel a kapcsolatot a Felhom csapattal a beállításhoz.
| Alkalmazás | +Típus | +Méret | +Utolsó | +Érvényesítés | +Állapot | +
|---|---|---|---|---|---|
| {{.DB.StackName}} | +{{dbTypeLabel .DB.DBType}} | +{{if .Error}}–{{else}}{{fmtBytes .Size}}{{end}} | +{{if .Error}}–{{else}}{{fmtTimeShort $.Backup.LastDBDump.LastRun}}{{end}} | ++ {{if .Error}} + – + {{else if .Validation.Valid}} + {{.Validation.TableCount}} tábla + {{else}} + Hiba + {{end}} + | ++ {{if .Error}} + Hiba + {{else}} + OK + {{end}} + | +
| {{.StackName}} | +{{dbTypeLabel .DBType}} | +{{fmtBytes .Size}} | +{{fmtTimeShort .ModTime}} | ++ {{if .Validation.Valid}} + {{.Validation.TableCount}} tábla + {{else if .Validation.Error}} + Hiba + {{else}} + – + {{end}} + | +OK | +
| Azonosító | +Időpont | +Méret | +Új fájl | +Változott | +
|---|---|---|---|---|
| {{shortID .SnapshotID}} | +{{fmtTime .Time}} | +{{if .HasStats}}+{{.DataAdded}}{{else}}–{{end}} | +{{if .HasStats}}{{.FilesNew}}{{else}}–{{end}} | +{{if .HasStats}}{{.FilesChanged}}{{else}}–{{end}} | +