v0.12.0 — Backup page overhaul: unified app rows, bug fixes, sequential chaining
Bug fixes: - GetFullStatus() returns deep copy; CrossDriveSummary/UnconfiguredApps/CrossDriveWarnings are always nil in the copy so the handler builds them fresh (fixes duplicate-apps bug) - Replace binary IsMountPoint check with tiered CheckBackupDestination() — path-not-exist, not-writable, system-drive (warning), disk >90-95% full; shown as warning vs critical - Remove dead settingsAppBackupHandler / POST /settings/app-backup route (toggle wrote to settings.json but nothing consumed the flag) Architecture: - Unified per-app backup rows: new AppBackupRow struct + buildAppBackupRows() replaces the two old sections with expandable rows showing all 3 layers per app - Sequential backup chaining: cross-drive runs immediately after restic (removed independent cross-drive-daily/cross-drive-weekly scheduler jobs) - Deploy page: remove "Csak kézi indítás" schedule option; add weekly consistency note Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -192,6 +192,18 @@ func main() {
|
||||
if err != nil {
|
||||
notifier.NotifyBackupFailed("Biztonsági mentés sikertelen", err.Error())
|
||||
}
|
||||
// Phase 3: Chain cross-drive backups immediately after restic (regardless of restic success)
|
||||
// Daily jobs run every night; weekly jobs only on Sunday
|
||||
if crossDriveRunner != nil {
|
||||
if cdErr := crossDriveRunner.RunAllScheduled(ctx, "daily"); cdErr != nil {
|
||||
logger.Printf("[WARN] Cross-drive daily backup had errors: %v", cdErr)
|
||||
}
|
||||
if time.Now().Weekday() == time.Sunday {
|
||||
if cdErr := crossDriveRunner.RunAllScheduled(ctx, "weekly"); cdErr != nil {
|
||||
logger.Printf("[WARN] Cross-drive weekly backup had errors: %v", cdErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
return err
|
||||
})
|
||||
|
||||
@@ -216,18 +228,6 @@ func main() {
|
||||
})
|
||||
}
|
||||
|
||||
// Cross-drive backup — daily at 03:30 (after main backup at 03:00)
|
||||
sched.Daily("cross-drive-daily", "03:30", func(ctx context.Context) error {
|
||||
return crossDriveRunner.RunAllScheduled(ctx, "daily")
|
||||
})
|
||||
// Cross-drive weekly — Sunday 04:30 (after integrity check at 04:00)
|
||||
sched.Daily("cross-drive-weekly", "04:30", func(ctx context.Context) error {
|
||||
if time.Now().Weekday() != time.Sunday {
|
||||
return nil
|
||||
}
|
||||
return crossDriveRunner.RunAllScheduled(ctx, "weekly")
|
||||
})
|
||||
|
||||
// Metrics prune — daily at 04:00
|
||||
if metricsStore != nil {
|
||||
sched.Daily("metrics-prune", "04:00", func(ctx context.Context) error {
|
||||
|
||||
@@ -585,29 +585,43 @@ func (m *Manager) RefreshCache(nextDBDump, nextBackup time.Time) {
|
||||
|
||||
// GetFullStatus returns the cached backup status for page rendering.
|
||||
// Returns instantly — no subprocess calls.
|
||||
// Returns a deep copy so callers can safely append to slice fields without
|
||||
// polluting the cache (which would cause duplicate entries on repeated calls).
|
||||
func (m *Manager) GetFullStatus(nextDBDump, nextBackup time.Time) *FullBackupStatus {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.cachedStatus != nil {
|
||||
// Deep copy — callers (backupsHandler) append to CrossDriveSummary,
|
||||
// UnconfiguredApps, and CrossDriveWarnings. If we returned the cache
|
||||
// pointer directly, every page load would accumulate more entries.
|
||||
status := *m.cachedStatus
|
||||
status.AppDataInfo = make([]AppBackupInfo, len(m.cachedStatus.AppDataInfo))
|
||||
copy(status.AppDataInfo, m.cachedStatus.AppDataInfo)
|
||||
// These three slices are assembled by the handler from AppDataInfo + settings;
|
||||
// they must always start empty so the handler builds them fresh.
|
||||
status.CrossDriveSummary = nil
|
||||
status.UnconfiguredApps = nil
|
||||
status.CrossDriveWarnings = nil
|
||||
|
||||
// Update dynamic fields that don't need subprocess calls
|
||||
m.cachedStatus.Running = m.running
|
||||
m.cachedStatus.NextDBDump = nextDBDump
|
||||
m.cachedStatus.NextBackup = nextBackup
|
||||
m.cachedStatus.LastDBDump = m.lastDBDump
|
||||
m.cachedStatus.LastBackup = m.lastBackup
|
||||
status.Running = m.running
|
||||
status.NextDBDump = nextDBDump
|
||||
status.NextBackup = nextBackup
|
||||
status.LastDBDump = m.lastDBDump
|
||||
status.LastBackup = m.lastBackup
|
||||
// Update snapshot history
|
||||
m.cachedStatus.SnapshotHistory = make([]SnapshotRecord, len(m.snapshotHistory))
|
||||
copy(m.cachedStatus.SnapshotHistory, m.snapshotHistory)
|
||||
status.SnapshotHistory = make([]SnapshotRecord, len(m.snapshotHistory))
|
||||
copy(status.SnapshotHistory, m.snapshotHistory)
|
||||
// Reverse so newest first
|
||||
for i, j := 0, len(m.cachedStatus.SnapshotHistory)-1; i < j; i, j = i+1, j-1 {
|
||||
m.cachedStatus.SnapshotHistory[i], m.cachedStatus.SnapshotHistory[j] = m.cachedStatus.SnapshotHistory[j], m.cachedStatus.SnapshotHistory[i]
|
||||
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]
|
||||
}
|
||||
|
||||
// Synthesize LastBackup from snapshot history if not in memory (e.g., after restart)
|
||||
if m.cachedStatus.LastBackup == nil && len(m.cachedStatus.SnapshotHistory) > 0 {
|
||||
latest := m.cachedStatus.SnapshotHistory[0] // already reversed, newest first
|
||||
m.cachedStatus.LastBackup = &BackupStatus{
|
||||
if status.LastBackup == nil && len(status.SnapshotHistory) > 0 {
|
||||
latest := status.SnapshotHistory[0] // already reversed, newest first
|
||||
status.LastBackup = &BackupStatus{
|
||||
LastRun: latest.Time,
|
||||
Success: latest.Success,
|
||||
Snapshot: &SnapshotResult{
|
||||
@@ -617,10 +631,10 @@ func (m *Manager) GetFullStatus(nextDBDump, nextBackup time.Time) *FullBackupSta
|
||||
}
|
||||
|
||||
// Synthesize LastDBDump from DumpFiles on disk if not in memory
|
||||
if m.cachedStatus.LastDBDump == nil && len(m.cachedStatus.DumpFiles) > 0 {
|
||||
if status.LastDBDump == nil && len(status.DumpFiles) > 0 {
|
||||
var results []DumpResult
|
||||
var latestTime time.Time
|
||||
for _, f := range m.cachedStatus.DumpFiles {
|
||||
for _, f := range status.DumpFiles {
|
||||
results = append(results, DumpResult{
|
||||
DB: DiscoveredDB{StackName: f.StackName, DBType: f.DBType, ContainerName: f.StackName},
|
||||
FilePath: f.FileName,
|
||||
@@ -630,14 +644,14 @@ func (m *Manager) GetFullStatus(nextDBDump, nextBackup time.Time) *FullBackupSta
|
||||
latestTime = f.ModTime
|
||||
}
|
||||
}
|
||||
m.cachedStatus.LastDBDump = &DBDumpStatus{
|
||||
status.LastDBDump = &DBDumpStatus{
|
||||
LastRun: latestTime,
|
||||
Results: results,
|
||||
Success: true,
|
||||
}
|
||||
}
|
||||
|
||||
return m.cachedStatus
|
||||
return &status
|
||||
}
|
||||
|
||||
// No cache yet — return a minimal status (first page load before cache is populated)
|
||||
|
||||
@@ -118,6 +118,85 @@ func GetFSInfo(path string) *FSInfo {
|
||||
return info
|
||||
}
|
||||
|
||||
// DestinationHealth holds the result of a tiered backup destination check.
|
||||
type DestinationHealth struct {
|
||||
Exists bool
|
||||
Writable bool
|
||||
MountPoint bool // true if path is on a different device from its parent
|
||||
SystemDrive bool // true if path is on the same device as /
|
||||
UsedPercent float64 // disk usage percentage (0 if unknown)
|
||||
FreeGB float64
|
||||
Warning string // human-readable warning message in Hungarian (empty = ok)
|
||||
Blocked bool // if true, backup must not run
|
||||
Severity string // "ok", "warning", "critical"
|
||||
}
|
||||
|
||||
// CheckBackupDestination performs tiered validation of a cross-drive backup destination.
|
||||
// Returns a DestinationHealth describing any issues found.
|
||||
func CheckBackupDestination(path string) DestinationHealth {
|
||||
h := DestinationHealth{Severity: "ok"}
|
||||
|
||||
// Tier 1: path must exist
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
h.Warning = "A cél tárhely (" + path + ") nem létezik!"
|
||||
h.Blocked = true
|
||||
h.Severity = "critical"
|
||||
return h
|
||||
}
|
||||
h.Exists = true
|
||||
|
||||
// Tier 2: path must be writable
|
||||
if !IsWritable(path) {
|
||||
h.Warning = "A cél tárhely (" + path + ") nem írható! Ellenőrizd a jogosultságokat."
|
||||
h.Blocked = true
|
||||
h.Severity = "critical"
|
||||
return h
|
||||
}
|
||||
h.Writable = true
|
||||
|
||||
// Tier 3: detect if source and destination are on the same block device
|
||||
// (stronger than IsMountPoint — catches e.g. bind mounts within same device)
|
||||
if isSameBlockDevice(path, "/") {
|
||||
h.SystemDrive = true
|
||||
// This is a warning, not a block — user data still protected against software errors
|
||||
h.Warning = "A cél tárhely (" + path + ") a rendszermeghajtón van. " +
|
||||
"Meghajtóhiba esetén az eredeti adat és a mentés is elveszhet. " +
|
||||
"Külső meghajtó használata javasolt."
|
||||
h.Severity = "warning"
|
||||
// Don't return early — also check disk usage
|
||||
} else {
|
||||
h.MountPoint = true
|
||||
}
|
||||
|
||||
// Tier 4: disk usage checks
|
||||
if di := GetDiskUsage(path); di != nil {
|
||||
h.UsedPercent = di.UsedPercent
|
||||
h.FreeGB = di.AvailGB
|
||||
if di.UsedPercent >= 95 {
|
||||
h.Warning = fmt.Sprintf("A mentési meghajtó megtelt (%.0f%% használt)!", di.UsedPercent)
|
||||
h.Blocked = true
|
||||
h.Severity = "critical"
|
||||
} else if di.UsedPercent >= 90 && h.Severity == "ok" {
|
||||
h.Warning = fmt.Sprintf("A mentési meghajtó majdnem megtelt (%.0f%% használt).", di.UsedPercent)
|
||||
h.Severity = "warning"
|
||||
}
|
||||
}
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
// isSameBlockDevice returns true if pathA and pathB are on the same block device.
|
||||
func isSameBlockDevice(pathA, pathB string) bool {
|
||||
var statA, statB syscall.Stat_t
|
||||
if err := syscall.Stat(pathA, &statA); err != nil {
|
||||
return false
|
||||
}
|
||||
if err := syscall.Stat(pathB, &statB); err != nil {
|
||||
return false
|
||||
}
|
||||
return statA.Dev == statB.Dev
|
||||
}
|
||||
|
||||
// diskModel reads the disk model from /sys/block/<dev>/device/model.
|
||||
func diskModel(device string) string {
|
||||
// /dev/sda1 → sda, /dev/nvme0n1p1 → nvme0n1
|
||||
|
||||
@@ -57,3 +57,26 @@ type FSInfo struct {
|
||||
|
||||
// GetFSInfo returns nil on non-Linux.
|
||||
func GetFSInfo(_ string) *FSInfo { return nil }
|
||||
|
||||
// DestinationHealth holds the result of a tiered backup destination check.
|
||||
type DestinationHealth struct {
|
||||
Exists bool
|
||||
Writable bool
|
||||
MountPoint bool
|
||||
SystemDrive bool
|
||||
UsedPercent float64
|
||||
FreeGB float64
|
||||
Warning string
|
||||
Blocked bool
|
||||
Severity string
|
||||
}
|
||||
|
||||
// CheckBackupDestination always returns ok on non-Linux (assume healthy for dev/testing).
|
||||
func CheckBackupDestination(path string) DestinationHealth {
|
||||
return DestinationHealth{
|
||||
Exists: true,
|
||||
Writable: true,
|
||||
MountPoint: true,
|
||||
Severity: "ok",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,19 +232,14 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
|
||||
}
|
||||
data["BackupDestPaths"] = destPaths
|
||||
|
||||
// Destination health warning
|
||||
// Destination health warning (tiered validation)
|
||||
if crossCfg != nil && crossCfg.Enabled && crossCfg.DestinationPath != "" {
|
||||
if !system.IsMountPoint(crossCfg.DestinationPath) || !system.IsWritable(crossCfg.DestinationPath) {
|
||||
data["BackupDestWarning"] = fmt.Sprintf(
|
||||
"A cél tárhely (%s) nem elérhető! Ellenőrizd a meghajtó csatlakozását.",
|
||||
crossCfg.DestinationPath,
|
||||
)
|
||||
health := system.CheckBackupDestination(crossCfg.DestinationPath)
|
||||
if health.Warning != "" {
|
||||
data["BackupDestWarning"] = health.Warning
|
||||
data["BackupDestWarningSeverity"] = health.Severity
|
||||
}
|
||||
}
|
||||
|
||||
// Nightly backup toggle state
|
||||
appBackupEnabled := s.settings.IsAppBackupEnabled(name)
|
||||
data["AppBackupEnabled"] = appBackupEnabled
|
||||
}
|
||||
|
||||
// Memory info for deploy page (only for non-deployed apps)
|
||||
@@ -457,15 +452,38 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
fullStatus.CrossDriveSummary = append(fullStatus.CrossDriveSummary, item)
|
||||
|
||||
// Destination health warning
|
||||
// Destination health warning (tiered validation)
|
||||
if cfg.Enabled && cfg.DestinationPath != "" {
|
||||
if !system.IsMountPoint(cfg.DestinationPath) || !system.IsWritable(cfg.DestinationPath) {
|
||||
health := system.CheckBackupDestination(cfg.DestinationPath)
|
||||
if health.Warning != "" {
|
||||
prefix := "⚠️"
|
||||
if health.Severity == "critical" {
|
||||
prefix = "🔴"
|
||||
}
|
||||
fullStatus.CrossDriveWarnings = append(fullStatus.CrossDriveWarnings,
|
||||
fmt.Sprintf("⚠️ %s mentési célja (%s) nem elérhető!", app.DisplayName, cfg.DestinationPath))
|
||||
fmt.Sprintf("%s %s: %s", prefix, app.DisplayName, health.Warning))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build unified per-app backup rows for the new UI
|
||||
data["AppBackupRows"] = s.buildAppBackupRows(fullStatus, crossConfigs, destLabels)
|
||||
|
||||
// Top-level warning: no user data backed up at all
|
||||
hasAnyCrossDrive := false
|
||||
hasAnyHDDApp := false
|
||||
for _, app := range fullStatus.AppDataInfo {
|
||||
if app.HasHDDData {
|
||||
hasAnyHDDApp = true
|
||||
if cfg, ok := crossConfigs[app.StackName]; ok && cfg != nil && cfg.Enabled {
|
||||
hasAnyCrossDrive = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if hasAnyHDDApp && !hasAnyCrossDrive {
|
||||
data["NoUserDataBackupWarning"] = true
|
||||
}
|
||||
|
||||
data["Backup"] = fullStatus
|
||||
|
||||
// Restic password for display
|
||||
@@ -479,38 +497,177 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
s.render(w, "backups", data)
|
||||
}
|
||||
|
||||
func (s *Server) settingsAppBackupHandler(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
// AppBackupRow holds all backup information for one app, used by the backup page template.
|
||||
type AppBackupRow struct {
|
||||
StackName string
|
||||
DisplayName string
|
||||
Status string // "green", "yellow", "red", "auto"
|
||||
StatusText string // short Hungarian tooltip
|
||||
|
||||
if s.backupMgr == nil {
|
||||
http.Redirect(w, r, "/backups", http.StatusFound)
|
||||
return
|
||||
// Storage info (HDD apps only)
|
||||
HasHDDData bool
|
||||
StorageLabel string
|
||||
HDDSizeHuman string
|
||||
|
||||
// Layer details (nil = layer not applicable)
|
||||
HasDB bool
|
||||
DBLastRun string // formatted time
|
||||
DBLastStatus string // "ok", "error", ""
|
||||
|
||||
VolumeLastRun string
|
||||
VolumeLastStatus string
|
||||
|
||||
// Cross-drive / user data
|
||||
HasUserData bool
|
||||
UserDataConfigured bool
|
||||
UserDataMethod string // "rsync", "restic"
|
||||
UserDataDest string // destination label
|
||||
UserDataSchedule string // "Naponta", "Hetente"
|
||||
UserDataLastRun string
|
||||
UserDataLastStatus string // "ok", "error", "running", ""
|
||||
UserDataLastError string
|
||||
UserDataStatusBadge string // "Sikeres", "Hiba", "Fut...", "—"
|
||||
|
||||
// Warnings accumulated for this app
|
||||
Warnings []string
|
||||
}
|
||||
|
||||
// buildAppBackupRows constructs one AppBackupRow per deployed app for the unified backup page.
|
||||
func (s *Server) buildAppBackupRows(
|
||||
status *backup.FullBackupStatus,
|
||||
crossConfigs map[string]*settings.CrossDriveBackup,
|
||||
destLabels map[string]string,
|
||||
) []AppBackupRow {
|
||||
loc, _ := time.LoadLocation("Europe/Budapest")
|
||||
|
||||
// Build a quick lookup: which stacks have a DB dump?
|
||||
dbStacks := make(map[string]bool)
|
||||
for _, db := range status.DiscoveredDBs {
|
||||
dbStacks[db.StackName] = true
|
||||
}
|
||||
// Also check dump files if no live discovered DBs
|
||||
for _, f := range status.DumpFiles {
|
||||
dbStacks[f.StackName] = true
|
||||
}
|
||||
|
||||
// Get current app data info to know which stacks have HDD data
|
||||
nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule)
|
||||
nextBackup := scheduler.NextDailyRun(s.cfg.Backup.ResticSchedule)
|
||||
fullStatus := s.backupMgr.GetFullStatus(nextDBDump, nextBackup)
|
||||
|
||||
prefs := make(map[string]bool)
|
||||
for _, app := range fullStatus.AppDataInfo {
|
||||
if app.HasHDDData {
|
||||
prefs[app.StackName] = r.FormValue("backup_"+app.StackName) == "on"
|
||||
// Determine last restic run time for volume backup display
|
||||
volumeLastRun := ""
|
||||
volumeLastStatus := ""
|
||||
if status.LastBackup != nil {
|
||||
volumeLastRun = status.LastBackup.LastRun.In(loc).Format("01-02 15:04")
|
||||
if status.LastBackup.Success {
|
||||
volumeLastStatus = "ok"
|
||||
} else {
|
||||
volumeLastStatus = "error"
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.settings.SetAppBackupBulk(prefs); err != nil {
|
||||
s.logger.Printf("[ERROR] Failed to save app backup prefs: %v", err)
|
||||
http.Redirect(w, r, "/backups?flash_error=Hiba+a+ment%C3%A9skor", http.StatusFound)
|
||||
return
|
||||
// DB dump last run
|
||||
dbLastRun := ""
|
||||
dbLastStatus := ""
|
||||
if status.LastDBDump != nil {
|
||||
dbLastRun = status.LastDBDump.LastRun.In(loc).Format("01-02 15:04")
|
||||
if status.LastDBDump.Success {
|
||||
dbLastStatus = "ok"
|
||||
} else {
|
||||
dbLastStatus = "error"
|
||||
}
|
||||
}
|
||||
|
||||
s.logger.Printf("[INFO] App backup preferences updated: %v", prefs)
|
||||
var rows []AppBackupRow
|
||||
for _, app := range status.AppDataInfo {
|
||||
row := AppBackupRow{
|
||||
StackName: app.StackName,
|
||||
DisplayName: app.DisplayName,
|
||||
HasHDDData: app.HasHDDData,
|
||||
StorageLabel: app.StorageLabel,
|
||||
HDDSizeHuman: app.HDDSizeHuman,
|
||||
HasDB: dbStacks[app.StackName] || app.HasDBDump,
|
||||
DBLastRun: dbLastRun,
|
||||
DBLastStatus: dbLastStatus,
|
||||
VolumeLastRun: volumeLastRun,
|
||||
VolumeLastStatus: volumeLastStatus,
|
||||
}
|
||||
|
||||
// Trigger cache refresh so the page shows updated data
|
||||
go s.backupMgr.RefreshCache(nextDBDump, nextBackup)
|
||||
// Default status = green/auto
|
||||
row.Status = "auto"
|
||||
row.StatusText = "Automatikus mentés"
|
||||
|
||||
http.Redirect(w, r, "/backups?flash=Alkalmaz%C3%A1s+ment%C3%A9si+be%C3%A1ll%C3%ADt%C3%A1sok+mentve.", http.StatusFound)
|
||||
if app.HasHDDData {
|
||||
row.HasUserData = true
|
||||
cfg, hasCfg := crossConfigs[app.StackName]
|
||||
|
||||
if !hasCfg || cfg == nil || !cfg.Enabled {
|
||||
// HDD data but no cross-drive configured → RED
|
||||
row.UserDataConfigured = false
|
||||
row.Status = "red"
|
||||
row.StatusText = "Felhasználói adatokról nincs mentés"
|
||||
} else {
|
||||
row.UserDataConfigured = true
|
||||
row.UserDataMethod = cfg.Method
|
||||
row.UserDataDest = destLabels[cfg.DestinationPath]
|
||||
if row.UserDataDest == "" {
|
||||
row.UserDataDest = cfg.DestinationPath
|
||||
}
|
||||
switch cfg.Schedule {
|
||||
case "daily":
|
||||
row.UserDataSchedule = "Naponta"
|
||||
case "weekly":
|
||||
row.UserDataSchedule = "Hetente (vasárnap)"
|
||||
default:
|
||||
row.UserDataSchedule = cfg.Schedule
|
||||
}
|
||||
if cfg.LastRun != "" {
|
||||
if t, err := time.Parse(time.RFC3339, cfg.LastRun); err == nil {
|
||||
row.UserDataLastRun = t.In(loc).Format("01-02 15:04")
|
||||
}
|
||||
}
|
||||
row.UserDataLastStatus = cfg.LastStatus
|
||||
row.UserDataLastError = cfg.LastError
|
||||
switch cfg.LastStatus {
|
||||
case "ok":
|
||||
row.UserDataStatusBadge = "Sikeres"
|
||||
case "error":
|
||||
row.UserDataStatusBadge = "Hiba"
|
||||
case "running":
|
||||
row.UserDataStatusBadge = "Fut..."
|
||||
default:
|
||||
row.UserDataStatusBadge = "—"
|
||||
}
|
||||
|
||||
// Check destination health for status determination
|
||||
health := system.CheckBackupDestination(cfg.DestinationPath)
|
||||
if health.Blocked {
|
||||
row.Status = "red"
|
||||
row.StatusText = "Mentési cél nem elérhető"
|
||||
row.Warnings = append(row.Warnings, health.Warning)
|
||||
} else if health.Warning != "" {
|
||||
row.Status = "yellow"
|
||||
row.StatusText = "Figyelmeztetés"
|
||||
row.Warnings = append(row.Warnings, health.Warning)
|
||||
} else if cfg.LastStatus == "error" {
|
||||
row.Status = "yellow"
|
||||
row.StatusText = "Utolsó mentés sikertelen"
|
||||
} else {
|
||||
row.Status = "green"
|
||||
row.StatusText = "Mentés rendben"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No HDD data — fully automatic
|
||||
row.Status = "auto"
|
||||
row.StatusText = "Automatikus mentés (nincs felhasználói adat)"
|
||||
}
|
||||
|
||||
// If DB dump failed for this app, degrade to yellow (if not already red)
|
||||
if row.HasDB && dbLastStatus == "error" && row.Status != "red" {
|
||||
row.Status = "yellow"
|
||||
row.StatusText = "Adatbázis mentés sikertelen"
|
||||
}
|
||||
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// settingsCrossBackupHandler handles POST /settings/cross-backup/{name}
|
||||
@@ -532,7 +689,7 @@ func (s *Server) settingsCrossBackupHandler(w http.ResponseWriter, r *http.Reque
|
||||
if method != "rsync" && method != "restic" {
|
||||
method = "rsync"
|
||||
}
|
||||
if schedule != "daily" && schedule != "weekly" && schedule != "manual" {
|
||||
if schedule != "daily" && schedule != "weekly" {
|
||||
schedule = "daily"
|
||||
}
|
||||
} else if existing != nil {
|
||||
|
||||
@@ -110,8 +110,6 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.settingsStorageSchedulableHandler(w, r)
|
||||
case path == "/settings/storage/label" && r.Method == http.MethodPost:
|
||||
s.settingsStorageLabelHandler(w, r)
|
||||
case path == "/settings/app-backup" && r.Method == http.MethodPost:
|
||||
s.settingsAppBackupHandler(w, r)
|
||||
case strings.HasPrefix(path, "/settings/cross-backup/") && r.Method == http.MethodPost:
|
||||
name := strings.TrimPrefix(path, "/settings/cross-backup/")
|
||||
s.settingsCrossBackupHandler(w, r, name)
|
||||
|
||||
@@ -233,84 +233,109 @@
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- Section 4: App data backup status (read-only) -->
|
||||
<!-- Section 4: Unified per-app backup status -->
|
||||
{{if .Backup.AppDataInfo}}
|
||||
<div class="backup-section-card">
|
||||
<h3>Alkalmazás adatok</h3>
|
||||
<p class="backup-section-desc">Az alkalmazások felhasználói adatainak mentési állapota. Beállítás az alkalmazás oldalán.</p>
|
||||
<div class="app-backup-list">
|
||||
{{range .Backup.AppDataInfo}}
|
||||
<div class="app-backup-item">
|
||||
<div class="app-backup-header">
|
||||
<a href="/stacks/{{.StackName}}/deploy" class="app-backup-name-link">{{.DisplayName}}</a>
|
||||
<div class="app-backup-status-row">
|
||||
{{if .HasHDDData}}
|
||||
{{if .StorageLabel}}<span class="meta-badge meta-badge-storage">{{.StorageLabel}}</span>{{end}}
|
||||
{{if .BackupEnabled}}
|
||||
<span class="app-backup-size mono">{{.HDDSizeHuman}}</span>
|
||||
<span class="app-backup-status app-backup-active">Aktív</span>
|
||||
{{else}}
|
||||
<span class="app-backup-status app-backup-inactive">Inaktív</span>
|
||||
<h3>Alkalmazások mentési állapota</h3>
|
||||
|
||||
{{if .NoUserDataBackupWarning}}
|
||||
<div class="alert alert-error" style="margin-bottom:1.5rem">
|
||||
<strong>Felhasználói adatokról nincs biztonsági mentés.</strong><br>
|
||||
A szerveren tárolt fotók, dokumentumok és egyéb fájlok jelenleg csak egy példányban léteznek.
|
||||
Külső meghajtó csatlakoztatásával biztonsági másolat készíthető a 3-2-1 szabály szerint.
|
||||
<a href="/settings" style="color:inherit;text-decoration:underline">Meghajtó beállítása →</a>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{range .AppBackupRows}}
|
||||
<div class="app-backup-row" data-status="{{.Status}}">
|
||||
<div class="app-backup-row-header" onclick="toggleBackupDetail(this)">
|
||||
<span class="status-dot status-{{.Status}}" title="{{.StatusText}}"></span>
|
||||
<span class="app-backup-row-name">{{.DisplayName}}</span>
|
||||
<div class="app-backup-row-meta">
|
||||
{{if .HasHDDData}}
|
||||
{{if .StorageLabel}}<span class="meta-badge meta-badge-storage">{{.StorageLabel}}</span>{{end}}
|
||||
<span class="mono app-backup-size" style="font-size:.8rem">{{.HDDSizeHuman}}</span>
|
||||
{{else}}
|
||||
<span class="meta-badge">Auto</span>
|
||||
{{end}}
|
||||
</div>
|
||||
<span class="expand-icon">▶</span>
|
||||
</div>
|
||||
<div class="app-backup-row-detail" style="display:none">
|
||||
<div class="backup-layers">
|
||||
<!-- DB layer -->
|
||||
<div class="backup-layer-row">
|
||||
<span class="layer-label">Adatbázis mentés</span>
|
||||
{{if .HasDB}}
|
||||
<span class="layer-badge">Auto</span>
|
||||
{{if .DBLastRun}}
|
||||
<span class="layer-last">Utolsó: {{.DBLastRun}}
|
||||
{{if eq .DBLastStatus "ok"}}<span class="text-ok">✓</span>
|
||||
{{else if eq .DBLastStatus "error"}}<span class="text-error">✗</span>{{end}}
|
||||
</span>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<span class="app-backup-status app-backup-na">N/A</span>
|
||||
<span class="layer-na">— (nincs adatbázis)</span>
|
||||
{{end}}
|
||||
</div>
|
||||
<!-- Volume layer -->
|
||||
<div class="backup-layer-row">
|
||||
<span class="layer-label">Docker kötetek</span>
|
||||
<span class="layer-badge">Auto</span>
|
||||
{{if .VolumeLastRun}}
|
||||
<span class="layer-last">Utolsó: {{.VolumeLastRun}}
|
||||
{{if eq .VolumeLastStatus "ok"}}<span class="text-ok">✓</span>
|
||||
{{else if eq .VolumeLastStatus "error"}}<span class="text-error">✗</span>{{end}}
|
||||
</span>
|
||||
{{end}}
|
||||
</div>
|
||||
<!-- User data layer -->
|
||||
<div class="backup-layer-row{{if not .HasHDDData}} layer-row-na{{end}}">
|
||||
<span class="layer-label">Felhasználói adatok</span>
|
||||
{{if .HasUserData}}
|
||||
{{if .UserDataConfigured}}
|
||||
<span class="layer-method">{{.UserDataMethod}}</span>
|
||||
<span class="layer-dest">→ {{.UserDataDest}}</span>
|
||||
<span class="layer-schedule">{{.UserDataSchedule}}</span>
|
||||
{{if .UserDataLastRun}}
|
||||
<span class="layer-last">Utolsó: {{.UserDataLastRun}}
|
||||
<span class="{{if eq .UserDataLastStatus "ok"}}text-ok{{else if eq .UserDataLastStatus "error"}}text-error{{else if eq .UserDataLastStatus "running"}}text-muted{{end}}">
|
||||
{{.UserDataStatusBadge}}
|
||||
</span>
|
||||
</span>
|
||||
{{end}}
|
||||
<div class="layer-actions">
|
||||
<a href="/stacks/{{.StackName}}/deploy" class="btn btn-xs btn-outline">Beállítás</a>
|
||||
<button class="btn btn-xs btn-outline"
|
||||
onclick="triggerCrossDriveBackup('{{.StackName}}', this)">
|
||||
Futtatás most</button>
|
||||
</div>
|
||||
{{else}}
|
||||
<span class="layer-unconfigured">⚠ Nincs beállítva</span>
|
||||
<a href="/stacks/{{.StackName}}/deploy" class="btn btn-xs">Beállítás →</a>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<span class="layer-na">— (nincs HDD adat)</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{if .Warnings}}
|
||||
<div class="layer-warnings">
|
||||
{{range .Warnings}}
|
||||
<div class="backup-layer-warning">{{.}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Section 4b: Cross-drive backups -->
|
||||
{{if or .Backup.CrossDriveSummary .Backup.UnconfiguredApps}}
|
||||
<div class="backup-section-card">
|
||||
<h3>Másolatok másik meghajtóra</h3>
|
||||
<p class="backup-section-desc">Alkalmazás adatok biztonsági másolata külső meghajtóra (3-2-1 szabály).</p>
|
||||
|
||||
{{if .Backup.CrossDriveWarnings}}
|
||||
<div style="margin-bottom:1rem">
|
||||
{{range .Backup.CrossDriveWarnings}}
|
||||
<div class="alert alert-warning" style="margin-bottom:.5rem">{{.}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Backup.CrossDriveSummary}}
|
||||
<div class="cross-drive-list" style="margin-bottom:1rem">
|
||||
{{range .Backup.CrossDriveSummary}}
|
||||
<div class="cross-drive-item">
|
||||
<div class="cross-drive-header">
|
||||
<a href="/stacks/{{.StackName}}/deploy" class="cross-drive-name">{{.DisplayName}}</a>
|
||||
<div class="cross-drive-meta">
|
||||
<span class="meta-badge">{{.MethodLabel}}</span>
|
||||
{{if .DestLabel}}<span class="meta-badge meta-badge-storage">→ {{.DestLabel}}</span>
|
||||
{{else if .DestPath}}<span class="meta-badge meta-badge-storage">→ {{.DestPath}}</span>{{end}}
|
||||
{{if eq .LastStatus "ok"}}<span class="meta-badge meta-badge-ok">{{.LastRunShort}}</span>
|
||||
{{else if eq .LastStatus "error"}}<span class="meta-badge meta-badge-fail">Hiba</span>
|
||||
{{else if eq .LastStatus "running"}}<span class="meta-badge">Fut...</span>
|
||||
{{else}}<span class="meta-badge" style="color:var(--text-muted)">{{.ScheduleLabel}}</span>{{end}}
|
||||
{{if .SizeHuman}}<span class="mono" style="font-size:.8rem;color:var(--text-muted)">{{.SizeHuman}}</span>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="cross-drive-actions" style="margin-top:1rem">
|
||||
<button class="btn btn-sm btn-outline" onclick="triggerAllCrossDrive(this)">Összes HDD mentés futtatása most</button>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Backup.UnconfiguredApps}}
|
||||
<div style="font-size:.85rem;color:var(--yellow);margin-bottom:1rem">
|
||||
{{len .Backup.UnconfiguredApps}} alkalmazáshoz nincs beállítva:
|
||||
{{range .Backup.UnconfiguredApps}}
|
||||
<a href="/stacks/{{.StackName}}/deploy" style="color:var(--accent-blue)">{{.DisplayName}}</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="cross-drive-actions">
|
||||
<button class="btn btn-sm btn-primary" onclick="triggerAllCrossDrive(this)">Összes futtatása most</button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -463,6 +488,40 @@
|
||||
{{end}}
|
||||
|
||||
<script>
|
||||
function toggleBackupDetail(header) {
|
||||
var detail = header.nextElementSibling;
|
||||
var icon = header.querySelector('.expand-icon');
|
||||
if (detail.style.display === 'none') {
|
||||
detail.style.display = 'block';
|
||||
icon.textContent = '▼';
|
||||
} else {
|
||||
detail.style.display = 'none';
|
||||
icon.textContent = '▶';
|
||||
}
|
||||
}
|
||||
|
||||
function triggerCrossDriveBackup(stackName, btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Fut...';
|
||||
fetch('/api/stacks/' + stackName + '/cross-backup/run', {method: 'POST'})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (!d.ok) {
|
||||
alert('Hiba: ' + (d.error || 'Ismeretlen hiba'));
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Futtatás most';
|
||||
return;
|
||||
}
|
||||
btn.textContent = 'Fut...';
|
||||
setTimeout(function() { location.reload(); }, 5000);
|
||||
})
|
||||
.catch(function(e) {
|
||||
alert('Hálózati hiba: ' + e.message);
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Futtatás most';
|
||||
});
|
||||
}
|
||||
|
||||
function triggerAllCrossDrive(btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Indítás...';
|
||||
|
||||
@@ -98,26 +98,18 @@
|
||||
<h4>Biztonsági mentés</h4>
|
||||
|
||||
<div class="cross-drive-nightly">
|
||||
<div class="cross-drive-nightly-status">
|
||||
{{if .AppBackupEnabled}}
|
||||
<span class="nightly-status-indicator nightly-enabled"></span>
|
||||
{{else}}
|
||||
<span class="nightly-status-indicator nightly-disabled"></span>
|
||||
{{end}}
|
||||
<span class="toggle-label">Napi mentésbe foglalás (restic, helyi)</span>
|
||||
</div>
|
||||
<span class="form-hint" style="display:block;margin-top:.25rem">
|
||||
Az alkalmazás adatai bekerülnek az éjszakai biztonsági mentésbe.
|
||||
<a href="/backups" style="color:var(--accent-blue)">Beállítás a mentési oldalon</a>
|
||||
Az alkalmazás adatbázisa és Docker kötetei automatikusan bekerülnek az éjszakai biztonsági mentésbe.
|
||||
<a href="/backups" style="color:var(--accent-blue)">Mentési állapot →</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<hr style="border-color:var(--border);margin:1rem 0">
|
||||
|
||||
<p style="font-weight:500;margin-bottom:1rem">Másolat másik meghajtóra:</p>
|
||||
<p style="font-weight:500;margin-bottom:1rem">Másolat másik meghajtóra (felhasználói adatok):</p>
|
||||
|
||||
{{if .BackupDestWarning}}
|
||||
<div class="alert alert-warning" style="margin-bottom:1rem">{{.BackupDestWarning}}</div>
|
||||
<div class="alert {{if eq .BackupDestWarningSeverity "critical"}}alert-error{{else}}alert-warning{{end}}" style="margin-bottom:1rem">{{.BackupDestWarning}}</div>
|
||||
{{end}}
|
||||
|
||||
{{if not .BackupDestPaths}}
|
||||
@@ -177,18 +169,23 @@
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">Ütemezés</span>
|
||||
<select name="cross_drive_schedule" id="cd-schedule" class="form-control cross-drive-field" style="max-width:20rem"
|
||||
{{if not (and .CrossDriveConfig .CrossDriveConfig.Enabled)}}disabled{{end}}>
|
||||
<option value="daily" {{if and .CrossDriveConfig (eq .CrossDriveConfig.Schedule "daily")}}selected{{end}}>
|
||||
Naponta (03:30)
|
||||
</option>
|
||||
<option value="weekly" {{if and .CrossDriveConfig (eq .CrossDriveConfig.Schedule "weekly")}}selected{{end}}>
|
||||
Hetente (vasárnap 04:30)
|
||||
</option>
|
||||
<option value="manual" {{if and .CrossDriveConfig (eq .CrossDriveConfig.Schedule "manual")}}selected{{end}}>
|
||||
Csak kézi indítás
|
||||
</option>
|
||||
</select>
|
||||
<div>
|
||||
<select name="cross_drive_schedule" id="cd-schedule" class="form-control cross-drive-field" style="max-width:20rem"
|
||||
{{if not (and .CrossDriveConfig .CrossDriveConfig.Enabled)}}disabled{{end}}
|
||||
onchange="onScheduleChange()">
|
||||
<option value="daily" {{if and .CrossDriveConfig (eq .CrossDriveConfig.Schedule "daily")}}selected{{end}}>
|
||||
Naponta (az éjszakai mentés után)
|
||||
</option>
|
||||
<option value="weekly" {{if or (and .CrossDriveConfig (eq .CrossDriveConfig.Schedule "weekly")) (and .CrossDriveConfig (eq .CrossDriveConfig.Schedule "manual"))}}selected{{end}}>
|
||||
Hetente, vasárnap (az éjszakai mentés után)
|
||||
</option>
|
||||
</select>
|
||||
<div id="weekly-note" class="form-hint" style="margin-top:.5rem;display:{{if and .CrossDriveConfig (or (eq .CrossDriveConfig.Schedule "weekly") (eq .CrossDriveConfig.Schedule "manual"))}}block{{else}}none{{end}}">
|
||||
ℹ Heti mentés esetén visszaállításkor az adatbázis is a mentés napjára áll vissza
|
||||
a konzisztencia érdekében. A mentés napja és a visszaállítás között keletkezett
|
||||
adatbázis-változások elvesznek (max. 7 nap).
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -382,6 +379,14 @@ function toggleCrossDriveFields() {
|
||||
}
|
||||
}
|
||||
|
||||
function onScheduleChange() {
|
||||
var sel = document.getElementById('cd-schedule');
|
||||
var note = document.getElementById('weekly-note');
|
||||
if (sel && note) {
|
||||
note.style.display = sel.value === 'weekly' ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function triggerCrossDriveBackup(stackName, btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Mentés folyamatban...';
|
||||
|
||||
@@ -2436,3 +2436,140 @@ a.stat-card:hover {
|
||||
background: var(--red-bg);
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
/* ─── Unified App Backup Rows ──────────────────────────────────────── */
|
||||
.app-backup-row {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
margin-bottom: .5rem;
|
||||
background: var(--bg-secondary);
|
||||
overflow: hidden;
|
||||
}
|
||||
.app-backup-row[data-status="red"] { border-left: 3px solid var(--red); }
|
||||
.app-backup-row[data-status="yellow"] { border-left: 3px solid var(--yellow); }
|
||||
.app-backup-row[data-status="green"] { border-left: 3px solid var(--green); }
|
||||
.app-backup-row[data-status="auto"] { border-left: 3px solid var(--text-muted); }
|
||||
|
||||
.app-backup-row-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .75rem;
|
||||
padding: .65rem 1rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.app-backup-row-header:hover {
|
||||
background: var(--bg-hover, rgba(255,255,255,0.03));
|
||||
}
|
||||
.app-backup-row-name {
|
||||
font-weight: 500;
|
||||
font-size: .9rem;
|
||||
flex: 1;
|
||||
}
|
||||
.app-backup-row-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
}
|
||||
.expand-icon {
|
||||
color: var(--text-muted);
|
||||
font-size: .75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Status dot */
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
display: inline-block;
|
||||
}
|
||||
.status-dot.status-green { background: var(--green); box-shadow: 0 0 0 2px rgba(76,175,80,.2); }
|
||||
.status-dot.status-yellow { background: var(--yellow); box-shadow: 0 0 0 2px rgba(255,193,7,.2); }
|
||||
.status-dot.status-red { background: var(--red); box-shadow: 0 0 0 2px rgba(244,67,54,.2); }
|
||||
.status-dot.status-auto { background: var(--text-muted); opacity: .6; }
|
||||
|
||||
/* Expanded detail */
|
||||
.app-backup-row-detail {
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding: .75rem 1rem;
|
||||
}
|
||||
.backup-layers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .4rem;
|
||||
}
|
||||
.backup-layer-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: .4rem;
|
||||
font-size: .85rem;
|
||||
padding: .2rem 0;
|
||||
}
|
||||
.backup-layer-row.layer-row-na {
|
||||
opacity: .55;
|
||||
}
|
||||
.layer-label {
|
||||
font-weight: 500;
|
||||
min-width: 14rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.layer-badge {
|
||||
background: rgba(0,136,204,.1);
|
||||
color: var(--accent-light);
|
||||
padding: .1rem .45rem;
|
||||
border-radius: 3px;
|
||||
font-size: .75rem;
|
||||
}
|
||||
.layer-na {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
font-size: .82rem;
|
||||
}
|
||||
.layer-method {
|
||||
color: var(--text-secondary);
|
||||
font-size: .82rem;
|
||||
}
|
||||
.layer-dest {
|
||||
color: var(--text-secondary);
|
||||
font-size: .82rem;
|
||||
}
|
||||
.layer-schedule {
|
||||
color: var(--text-muted);
|
||||
font-size: .8rem;
|
||||
}
|
||||
.layer-last {
|
||||
color: var(--text-muted);
|
||||
font-size: .8rem;
|
||||
margin-left: .25rem;
|
||||
}
|
||||
.layer-unconfigured {
|
||||
color: var(--yellow);
|
||||
font-weight: 500;
|
||||
font-size: .85rem;
|
||||
}
|
||||
.layer-actions {
|
||||
display: flex;
|
||||
gap: .35rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
.btn-xs {
|
||||
padding: .15rem .5rem;
|
||||
font-size: .75rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.layer-warnings {
|
||||
margin-top: .5rem;
|
||||
padding-top: .5rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
.backup-layer-warning {
|
||||
font-size: .82rem;
|
||||
color: var(--yellow);
|
||||
padding: .2rem 0;
|
||||
}
|
||||
.text-ok { color: var(--green); }
|
||||
.text-error { color: var(--red); }
|
||||
.text-muted { color: var(--text-muted); }
|
||||
|
||||
Reference in New Issue
Block a user