v0.4.5: Add dedicated Backup page (Biztonsági mentés)
New /backups page with full backup system visibility: - Status overview cards (local/remote backup, DB count, repo size) - Schedule section with next-run times and retention policy - Database table with type, size, validation (table count), status - Snapshot history table with per-snapshot stats - Repository info card with paths, integrity status, remote placeholder - "Mentés most" button with auto-refresh polling - Empty state when backup not configured Backend: SnapshotRecord history (ring buffer), DumpValidation, ListDumpFiles, ListSnapshots, GetFullStatus, restic check tracking. Server accepts scheduler for next-run time calculation. Sidebar nav updated with 3rd item, dashboard backup card title clickable. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
{{define "backups"}}
|
||||
{{template "layout_start" .}}
|
||||
|
||||
<div class="page-header">
|
||||
<h2>Biztonsági mentés</h2>
|
||||
<span class="domain-badge">{{.Domain}}</span>
|
||||
</div>
|
||||
|
||||
{{if not .Backup}}
|
||||
<div class="backup-empty-state">
|
||||
<div class="backup-empty-icon">🛡</div>
|
||||
<h3>Biztonsági mentés nincs beállítva</h3>
|
||||
<p>A biztonsági mentés funkció nem aktív.<br>
|
||||
Kérjük, vegye fel a kapcsolatot a Felhom csapattal a beállításhoz.</p>
|
||||
</div>
|
||||
{{else}}
|
||||
|
||||
<!-- Section 1: Status overview cards -->
|
||||
<div class="stats-grid backup-page-cards">
|
||||
{{if .Backup.LastBackup}}
|
||||
{{if .Backup.LastBackup.Success}}
|
||||
<div class="stat-card stat-running">
|
||||
<div class="stat-value">✓</div>
|
||||
<div class="stat-label">Helyi mentés aktív</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="stat-card stat-stopped">
|
||||
<div class="stat-value">✗</div>
|
||||
<div class="stat-label">Helyi mentés sikertelen</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">–</div>
|
||||
<div class="stat-label">Helyi mentés</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="stat-card" style="border-left-color: var(--gray);">
|
||||
<div class="stat-value" style="background:var(--gray);-webkit-background-clip:text;background-clip:text;">–</div>
|
||||
<div class="stat-label">Távoli mentés<br><span class="relative-time">nincs beállítva</span></div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card stat-total">
|
||||
<div class="stat-value">
|
||||
{{if .Backup.LastDBDump}}{{len .Backup.LastDBDump.Results}}{{else}}{{len .Backup.DumpFiles}}{{end}}
|
||||
</div>
|
||||
<div class="stat-label">Adatbázis mentve</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card stat-total">
|
||||
<div class="stat-value">
|
||||
{{if .Backup.RepoStats}}{{.Backup.RepoStats.TotalSize}}{{else}}–{{end}}
|
||||
</div>
|
||||
<div class="stat-label">Tároló méret
|
||||
{{if .Backup.RepoStats}}<br><span class="relative-time">{{.Backup.RepoStats.SnapshotCount}} pillanatkép</span>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 2: Schedule -->
|
||||
<div class="schedule-card">
|
||||
<h3>Ütemezés</h3>
|
||||
<div class="schedule-rows">
|
||||
<div class="schedule-row">
|
||||
<span class="schedule-task">Adatbázis mentés</span>
|
||||
<span class="schedule-time">{{.Backup.DBDumpSchedule}}</span>
|
||||
<span class="schedule-next">Következő: {{nextRunLabel .Backup.NextDBDump}}</span>
|
||||
</div>
|
||||
<div class="schedule-row">
|
||||
<span class="schedule-task">Restic pillanatkép</span>
|
||||
<span class="schedule-time">{{.Backup.ResticSchedule}}</span>
|
||||
<span class="schedule-next">Következő: {{nextRunLabel .Backup.NextBackup}}</span>
|
||||
</div>
|
||||
<div class="schedule-row">
|
||||
<span class="schedule-task">Karbantartás</span>
|
||||
<span class="schedule-time">{{pruneLabel .Backup.PruneSchedule}}</span>
|
||||
<span class="schedule-next">Következő: {{nextPruneLabel .Backup.PruneSchedule}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="schedule-summary">
|
||||
{{if .Backup.LastBackup}}
|
||||
<div class="schedule-summary-row">
|
||||
<span>Utolsó sikeres mentés:</span>
|
||||
<span class="schedule-summary-value">{{fmtTime .Backup.LastBackup.LastRun}} ({{timeAgo .Backup.LastBackup.LastRun}})</span>
|
||||
</div>
|
||||
<div class="schedule-summary-row">
|
||||
<span>Mentés időtartam:</span>
|
||||
<span class="schedule-summary-value">{{fmtDuration .Backup.LastBackup.Duration}}</span>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="schedule-summary-row">
|
||||
<span>Utolsó sikeres mentés:</span>
|
||||
<span class="schedule-summary-value relative-time">Még nem futott</span>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="schedule-summary-row">
|
||||
<span>Megőrzés:</span>
|
||||
<span class="schedule-summary-value">{{.Backup.Retention.KeepDaily}} napi · {{.Backup.Retention.KeepWeekly}} heti · {{.Backup.Retention.KeepMonthly}} havi</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="schedule-actions">
|
||||
<button class="btn btn-sm btn-primary" onclick="triggerBackupFromPage()" id="backup-page-btn"
|
||||
{{if .Backup.Running}}disabled{{end}}>
|
||||
{{if .Backup.Running}}Mentés folyamatban...{{else}}Mentés most{{end}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 3: Databases -->
|
||||
<div class="backup-section-card">
|
||||
<h3>Adatbázisok</h3>
|
||||
{{if or .Backup.DumpFiles .Backup.DiscoveredDBs}}
|
||||
<div class="backup-table-wrap">
|
||||
<table class="db-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Alkalmazás</th>
|
||||
<th>Típus</th>
|
||||
<th>Méret</th>
|
||||
<th>Utolsó</th>
|
||||
<th>Érvényesítés</th>
|
||||
<th>Állapot</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{if .Backup.LastDBDump}}
|
||||
{{range .Backup.LastDBDump.Results}}
|
||||
<tr>
|
||||
<td>{{.DB.StackName}}</td>
|
||||
<td><span class="db-type-badge db-type-{{.DB.DBType}}">{{dbTypeLabel .DB.DBType}}</span></td>
|
||||
<td class="mono">{{if .Error}}–{{else}}{{fmtBytes .Size}}{{end}}</td>
|
||||
<td class="mono">{{if .Error}}–{{else}}{{fmtTimeShort $.Backup.LastDBDump.LastRun}}{{end}}</td>
|
||||
<td>
|
||||
{{if .Error}}
|
||||
<span class="validation-badge validation-na">–</span>
|
||||
{{else if .Validation.Valid}}
|
||||
<span class="validation-badge validation-ok">{{.Validation.TableCount}} tábla</span>
|
||||
{{else}}
|
||||
<span class="validation-badge validation-fail" title="{{.Validation.Error}}">Hiba</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if .Error}}
|
||||
<span class="validation-badge validation-fail" title="{{.Error}}">Hiba</span>
|
||||
{{else}}
|
||||
<span class="validation-badge validation-ok">OK</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{else}}
|
||||
{{range .Backup.DumpFiles}}
|
||||
<tr>
|
||||
<td>{{.StackName}}</td>
|
||||
<td><span class="db-type-badge db-type-{{.DBType}}">{{dbTypeLabel .DBType}}</span></td>
|
||||
<td class="mono">{{fmtBytes .Size}}</td>
|
||||
<td class="mono">{{fmtTimeShort .ModTime}}</td>
|
||||
<td>
|
||||
{{if .Validation.Valid}}
|
||||
<span class="validation-badge validation-ok">{{.Validation.TableCount}} tábla</span>
|
||||
{{else if .Validation.Error}}
|
||||
<span class="validation-badge validation-fail" title="{{.Validation.Error}}">Hiba</span>
|
||||
{{else}}
|
||||
<span class="validation-badge validation-na">–</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td><span class="validation-badge validation-ok">OK</span></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="backup-table-empty">Nem található adatbázis mentés.</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- Section 4: Snapshots -->
|
||||
<div class="backup-section-card">
|
||||
<h3>Pillanatképek</h3>
|
||||
{{if .Backup.SnapshotHistory}}
|
||||
<div class="backup-table-wrap">
|
||||
<table class="snapshot-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Azonosító</th>
|
||||
<th>Időpont</th>
|
||||
<th>Méret</th>
|
||||
<th>Új fájl</th>
|
||||
<th>Változott</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Backup.SnapshotHistory}}
|
||||
<tr>
|
||||
<td class="mono">{{shortID .SnapshotID}}</td>
|
||||
<td class="mono">{{fmtTime .Time}}</td>
|
||||
<td class="mono">{{if .HasStats}}+{{.DataAdded}}{{else}}–{{end}}</td>
|
||||
<td class="mono">{{if .HasStats}}{{.FilesNew}}{{else}}–{{end}}</td>
|
||||
<td class="mono">{{if .HasStats}}{{.FilesChanged}}{{else}}–{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="snapshot-footer">
|
||||
Összesen: {{len .Backup.SnapshotHistory}} pillanatkép
|
||||
{{if .Backup.RepoStats}} · {{.Backup.RepoStats.TotalSize}}{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="backup-table-empty">Még nincs pillanatkép.</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- Section 5: Repository -->
|
||||
<div class="repo-card">
|
||||
<h3>Tároló</h3>
|
||||
<div class="repo-info-rows">
|
||||
<div class="repo-info-row">
|
||||
<span class="repo-label">Helyszín:</span>
|
||||
<span class="repo-value mono">{{.Backup.RepoPath}} (helyi)</span>
|
||||
</div>
|
||||
{{if .Backup.RepoStats}}
|
||||
<div class="repo-info-row">
|
||||
<span class="repo-label">Méret:</span>
|
||||
<span class="repo-value">{{.Backup.RepoStats.TotalSize}}</span>
|
||||
</div>
|
||||
<div class="repo-info-row">
|
||||
<span class="repo-label">Pillanatképek:</span>
|
||||
<span class="repo-value">{{.Backup.RepoStats.SnapshotCount}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
<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 class="repo-paths">
|
||||
<span class="repo-label">Mentett útvonalak:</span>
|
||||
<ul class="repo-path-list">
|
||||
{{range .Backup.BackupPaths}}
|
||||
<li class="mono">{{.}}</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="repo-remote">
|
||||
<span class="repo-label">Távoli másolat:</span>
|
||||
<div class="repo-remote-status">
|
||||
<span class="relative-time">Nincs beállítva</span>
|
||||
<span class="relative-time">(B2/S3/SFTP támogatás hamarosan)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{end}}
|
||||
|
||||
<script>
|
||||
function triggerBackupFromPage() {
|
||||
const btn = document.getElementById('backup-page-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Mentés indítása...';
|
||||
fetch('/api/backup/run', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.ok) {
|
||||
btn.textContent = 'Mentés folyamatban...';
|
||||
btn.classList.add('loading');
|
||||
startBackupPolling();
|
||||
} else {
|
||||
btn.textContent = data.error || 'Hiba';
|
||||
setTimeout(() => { btn.textContent = 'Mentés most'; btn.disabled = false; }, 3000);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
btn.textContent = 'Hiba';
|
||||
setTimeout(() => { btn.textContent = 'Mentés most'; btn.disabled = false; }, 3000);
|
||||
});
|
||||
}
|
||||
|
||||
function startBackupPolling() {
|
||||
const poll = setInterval(() => {
|
||||
fetch('/api/backup/status')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.ok && data.data && !data.data.running) {
|
||||
clearInterval(poll);
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Auto-poll if backup is already running on page load
|
||||
{{if .Backup}}{{if .Backup.Running}}
|
||||
startBackupPolling();
|
||||
{{end}}{{end}}
|
||||
</script>
|
||||
|
||||
{{template "layout_end" .}}
|
||||
{{end}}
|
||||
@@ -82,7 +82,7 @@
|
||||
|
||||
{{if .BackupEnabled}}
|
||||
<div class="backup-status-card">
|
||||
<h3>Biztonsági mentés</h3>
|
||||
<h3><a href="/backups" class="backup-card-link">Biztonsági mentés</a></h3>
|
||||
{{if .BackupStatus}}
|
||||
<div class="backup-info-row">
|
||||
<span class="backup-label">Utolsó mentés:</span>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<ul class="nav-links">
|
||||
<li><a href="/" class="{{if eq .Page "dashboard"}}active{{end}}">Vezérlőpult</a></li>
|
||||
<li><a href="/stacks" class="{{if eq .Page "stacks"}}active{{end}}">Alkalmazások</a></li>
|
||||
<li><a href="/backups" class="{{if eq .Page "backups"}}active{{end}}">Biztonsági mentés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">v{{.Version}}</span>
|
||||
|
||||
@@ -1226,6 +1226,257 @@ a.stat-card:hover {
|
||||
.backup-status-fail { color: var(--red); }
|
||||
.backup-status-none { color: var(--text-muted); }
|
||||
|
||||
/* Dashboard backup card link */
|
||||
.backup-card-link {
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.backup-card-link:hover {
|
||||
color: var(--accent-light);
|
||||
}
|
||||
|
||||
/* --- Backup page --- */
|
||||
.backup-page-cards {
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
}
|
||||
|
||||
.backup-empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.backup-empty-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.backup-empty-state h3 {
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
.backup-empty-state p {
|
||||
color: var(--text-secondary);
|
||||
font-size: .9rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.schedule-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--border-color);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.schedule-card h3 {
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
.schedule-rows {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.schedule-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: .4rem 0;
|
||||
font-size: .85rem;
|
||||
border-bottom: 1px solid rgba(48, 54, 61, 0.5);
|
||||
}
|
||||
.schedule-row:last-child { border-bottom: none; }
|
||||
.schedule-task {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
min-width: 160px;
|
||||
}
|
||||
.schedule-time {
|
||||
color: var(--accent-light);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: .8rem;
|
||||
min-width: 80px;
|
||||
}
|
||||
.schedule-next {
|
||||
color: var(--text-secondary);
|
||||
font-size: .8rem;
|
||||
}
|
||||
.schedule-summary {
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: .75rem;
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
.schedule-summary-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: .25rem 0;
|
||||
font-size: .85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.schedule-summary-value {
|
||||
color: var(--text-primary);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: .8rem;
|
||||
}
|
||||
.schedule-actions {
|
||||
padding-top: .5rem;
|
||||
}
|
||||
|
||||
.backup-section-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--border-color);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.backup-section-card h3 {
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
|
||||
.backup-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.db-table, .snapshot-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: .85rem;
|
||||
}
|
||||
.db-table th, .snapshot-table th {
|
||||
text-align: left;
|
||||
padding: .5rem .75rem;
|
||||
color: var(--text-muted);
|
||||
font-size: .75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.db-table td, .snapshot-table td {
|
||||
padding: .5rem .75rem;
|
||||
color: var(--text-primary);
|
||||
border-bottom: 1px solid rgba(48, 54, 61, 0.3);
|
||||
}
|
||||
.db-table tr:nth-child(even), .snapshot-table tr:nth-child(even) {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
.db-table tr:nth-child(odd), .snapshot-table tr:nth-child(odd) {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
.db-table td.mono, .snapshot-table td.mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: .8rem;
|
||||
}
|
||||
|
||||
.db-type-badge {
|
||||
display: inline-block;
|
||||
padding: .1rem .5rem;
|
||||
border-radius: 999px;
|
||||
font-size: .75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.db-type-postgres {
|
||||
background: rgba(0, 136, 204, 0.15);
|
||||
color: var(--accent-light);
|
||||
}
|
||||
.db-type-mariadb {
|
||||
background: rgba(210, 153, 34, 0.15);
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.validation-badge {
|
||||
display: inline-block;
|
||||
padding: .1rem .5rem;
|
||||
border-radius: 999px;
|
||||
font-size: .75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.validation-ok {
|
||||
background: var(--green-bg);
|
||||
color: var(--green);
|
||||
}
|
||||
.validation-fail {
|
||||
background: var(--red-bg);
|
||||
color: var(--red);
|
||||
cursor: help;
|
||||
}
|
||||
.validation-na {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.snapshot-footer {
|
||||
padding: .75rem .75rem 0;
|
||||
font-size: .8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.backup-table-empty {
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: .85rem;
|
||||
}
|
||||
|
||||
.repo-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--border-color);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.repo-card h3 {
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
.repo-info-rows {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.repo-info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: .3rem 0;
|
||||
font-size: .85rem;
|
||||
}
|
||||
.repo-label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.repo-value {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.repo-value.mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: .8rem;
|
||||
}
|
||||
.repo-paths {
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: .75rem;
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
.repo-path-list {
|
||||
list-style: disc;
|
||||
padding-left: 1.5rem;
|
||||
margin-top: .5rem;
|
||||
}
|
||||
.repo-path-list li {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: .8rem;
|
||||
color: var(--text-secondary);
|
||||
padding: .15rem 0;
|
||||
}
|
||||
.repo-remote {
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: .75rem;
|
||||
}
|
||||
.repo-remote-status {
|
||||
margin-top: .25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .15rem;
|
||||
}
|
||||
|
||||
.relative-time {
|
||||
color: var(--text-muted);
|
||||
font-size: .8rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media(max-width: 768px) {
|
||||
.sidebar { width: 100%; height: auto; position: relative; border-right: none; border-bottom: 1px solid var(--border-color); }
|
||||
|
||||
Reference in New Issue
Block a user