package web import ( "context" "fmt" "net/http" "net/url" "os" "os/exec" "path/filepath" "strings" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/backup" "gitea.dooplex.hu/admin/felhom-controller/internal/scheduler" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" "gitea.dooplex.hu/admin/felhom-controller/internal/system" "golang.org/x/crypto/bcrypt" ) // StorageBarInfo holds data for rendering a storage usage bar on dashboard/monitoring. type StorageBarInfo struct { Label string // e.g., "USB HDD 1TB", "SYS Storage 350G" Path string // e.g., "/mnt/hdd_1" TotalGB float64 UsedGB float64 Percent float64 Disconnected bool } // buildStorageBars returns usage bars for all registered storage paths. func (s *Server) buildStorageBars() []StorageBarInfo { var bars []StorageBarInfo for _, sp := range s.settings.GetStoragePaths() { // Skip decommissioned drives — they are no longer in active use if sp.Decommissioned { continue } if sp.Disconnected { bars = append(bars, StorageBarInfo{ Label: sp.Label, Path: sp.Path, Disconnected: true, }) continue } di := system.GetDiskUsage(sp.Path) if di == nil { continue } bars = append(bars, StorageBarInfo{ Label: sp.Label, Path: sp.Path, TotalGB: di.TotalGB, UsedGB: di.UsedGB, Percent: di.UsedPercent, }) } return bars } // DeployStoragePath extends StoragePath with free space data for the deploy dropdown. type DeployStoragePath struct { settings.StoragePath FreeHuman string // "234.5 GB" FreePercent float64 // 67.5 } // StorageAppDetail holds info about an app using a specific storage path. type StorageAppDetail struct { Name string // Display name (e.g., "Immich") Stack string // Stack name (for link) SizeHuman string // Data size on this path } // StoragePathView extends StoragePath with display data for the settings page. type StoragePathView struct { settings.StoragePath DiskInfo *system.DiskUsageInfo AppCount int IsMounted bool AppDetails []StorageAppDetail FSInfo *system.FSInfo IsUSB bool // true if this is a USB-attached device (safe disconnect available) StoppedApps []string // stacks auto-stopped due to disconnect (for restart UI) MigratedToLabel string // label of the drive data was migrated to HasOtherPaths bool // true if other connected non-decommissioned paths exist } func (s *Server) baseData(page, title string) map[string]interface{} { data := map[string]interface{}{ "Page": page, "Title": title, "CustomerName": s.cfg.Customer.Name, "Domain": s.cfg.Customer.Domain, "Version": s.version, "AuthEnabled": s.authEnabled(), "DebugMode": s.isDebug(), } if s.alertManager != nil { data["Alerts"] = s.alertManager.GetAlerts() } return data } func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) { stackList := s.stackMgr.GetStacks() // Filter to deployed + protected stacks first var deployedStacks []stacks.Stack for _, st := range stackList { if st.Deployed || st.Protected { deployedStacks = append(deployedStacks, st) } } // Count from the DISPLAYED set only running, stopped := 0, 0 for _, st := range deployedStacks { switch st.State { case stacks.StateRunning, stacks.StateStarting, stacks.StateUnhealthy, stacks.StateRestarting: running++ case stacks.StateStopped, stacks.StateExited: stopped++ } } sysInfo := system.GetInfo(s.primaryHDDPath(), s.cpuCollector) data := s.baseData("dashboard", "Vezérlőpult") data["Stacks"] = deployedStacks data["RunningCount"] = running data["StoppedCount"] = stopped data["TotalCount"] = len(stackList) data["SystemInfo"] = sysInfo data["StorageBars"] = s.buildStorageBars() // Backup status data["BackupEnabled"] = s.cfg.Backup.Enabled 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["DBDumpStatus"] = fullStatus.LastDBDump data["BackupStatus"] = fullStatus.LastBackup data["BackupRunning"] = fullStatus.Running data["BackupMaxAgeHours"] = s.cfg.Monitoring.Thresholds.BackupMaxAgeHours // Cross-drive summary for dashboard Tier 2 status line crossConfigs := s.settings.GetAllCrossDriveConfigs() crossDriveTotal := 0 crossDriveConfigured := 0 crossDriveFailed := 0 for _, st := range deployedStacks { if st.Protected { continue } crossDriveTotal++ cfg, hasCfg := crossConfigs[st.Name] if hasCfg && cfg != nil && cfg.Enabled { crossDriveConfigured++ if cfg.LastStatus == "error" { crossDriveFailed++ } } } data["CrossDriveTotal"] = crossDriveTotal data["CrossDriveConfigured"] = crossDriveConfigured data["CrossDriveFailed"] = crossDriveFailed } if s.alertManager != nil { data["DiskWarnings"] = s.alertManager.GetInlineAlerts("dashboard") } s.executeTemplate(w, r, "dashboard", data) } func (s *Server) stacksHandler(w http.ResponseWriter, r *http.Request) { data := s.baseData("stacks", "Alkalmazások") data["Stacks"] = s.stackMgr.GetStacks() // Build storage label lookup for deployed apps storageLabels := make(map[string]string) // stack name → storage label storagePaths := s.settings.GetStoragePaths() for _, stack := range s.stackMgr.GetStacks() { if !stack.Deployed { continue } if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil { if hddPath := appCfg.Env["HDD_PATH"]; hddPath != "" { for _, sp := range storagePaths { if sp.Path == hddPath { storageLabels[stack.Name] = sp.Label break } } } } } data["StorageLabels"] = storageLabels // Build effective subdomain lookup (stored env > metadata fallback) subdomains := make(map[string]string) for _, stack := range s.stackMgr.GetStacks() { if stack.Deployed { if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil { if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd != "" { subdomains[stack.Name] = sd continue } } } subdomains[stack.Name] = stack.Meta.Subdomain } data["Subdomains"] = subdomains s.executeTemplate(w, r, "stacks", data) } func (s *Server) logsHandler(w http.ResponseWriter, r *http.Request, name string) { stack, ok := s.stackMgr.GetStack(name) if !ok { http.NotFound(w, r) return } logs, err := s.stackMgr.GetLogs(name, 200) if err != nil { logs = fmt.Sprintf("Hiba a naplók lekérésekor: %v", err) } // Raw mode: return plain text for AJAX polling if r.URL.Query().Get("raw") == "1" { w.Header().Set("Content-Type", "text/plain; charset=utf-8") fmt.Fprint(w, logs) return } data := s.baseData("logs", stack.Meta.DisplayName+" — Naplók") data["Stack"] = stack data["Logs"] = logs s.executeTemplate(w, r, "logs", data) } func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name string) { meta, appCfg, err := s.stackMgr.GetDeployFields(name) if err != nil { http.NotFound(w, r) return } stack, _ := s.stackMgr.GetStack(name) alreadyDeployed := appCfg != nil && appCfg.Deployed pageTitle := meta.DisplayName + " — Telepítés" if alreadyDeployed { pageTitle = meta.DisplayName + " — Beállítások" } data := s.baseData("deploy", pageTitle) data["Stack"] = stack data["Meta"] = meta data["AppConfig"] = appCfg data["AlreadyDeployed"] = alreadyDeployed data["LogoURL"] = s.cfg.AppLogoURL(meta.Slug) data["LogoPNGURL"] = s.cfg.AppLogoPNGURL(meta.Slug) data["AppPageURL"] = s.cfg.AppPageURL(meta.Slug) data["UserFields"] = meta.UserFacingFields() data["AutoFields"] = meta.AutoGeneratedFields() // Auto-generated field values: existing values for deployed apps, pre-generated for new deploys autoFieldValues := make(map[string]string) if alreadyDeployed && appCfg != nil { for _, f := range meta.AutoGeneratedFields() { if val, ok := appCfg.Env[f.EnvVar]; ok { autoFieldValues[f.EnvVar] = val } } } else if !alreadyDeployed { // Pre-generate values so the user sees (and can note down) domain/passwords before deploying. // These same values are submitted back in the form and saved to app.yaml. if preview, err := s.stackMgr.PreviewDeployValues(name); err == nil { autoFieldValues = preview } } data["AutoFieldValues"] = autoFieldValues // For deployed apps, pass stored field values so subdomain and other user fields show current values if alreadyDeployed && appCfg != nil { data["DeployedFieldValues"] = appCfg.Env } // Storage paths with free space info for deploy dropdown var deployPaths []DeployStoragePath for _, sp := range s.settings.GetSchedulableStoragePaths() { dp := DeployStoragePath{StoragePath: sp} if di := system.GetDiskUsage(sp.Path); di != nil { dp.FreeHuman = formatFreeSpace(di.AvailGB) if di.TotalGB > 0 { dp.FreePercent = di.AvailGB / di.TotalGB * 100 } } deployPaths = append(deployPaths, dp) } data["StoragePaths"] = deployPaths // Storage info for already-deployed apps with HDD data if alreadyDeployed { storageInfo := s.storageInfoForStack(name) if storageInfo != nil { data["StorageInfo"] = storageInfo data["OtherStoragePaths"] = s.otherStoragePathsForStack(name) } // Stale data from previous migrations (only for deployed apps with HDD data) staleData := s.findStaleStorageData(name) if len(staleData) > 0 { data["StaleData"] = staleData } // Cross-drive backup config for this app crossCfg := s.settings.GetCrossDriveConfig(name) data["CrossDriveConfig"] = crossCfg // Other storage paths for destination dropdown (exclude the app's current storage path) currentPath := "" if storageInfo != nil { currentPath = storageInfo.Path } var destPaths []DeployStoragePath for _, sp := range s.settings.GetStoragePaths() { if sp.Path == currentPath { continue // skip the app's current storage — must be a DIFFERENT physical device } dp := DeployStoragePath{StoragePath: sp} if di := system.GetDiskUsage(sp.Path); di != nil { dp.FreeHuman = formatFreeSpace(di.AvailGB) if di.TotalGB > 0 { dp.FreePercent = di.AvailGB / di.TotalGB * 100 } } destPaths = append(destPaths, dp) } data["BackupDestPaths"] = destPaths // Destination health warning (tiered validation) if crossCfg != nil && crossCfg.Enabled && crossCfg.DestinationPath != "" { health := system.CheckBackupDestination(crossCfg.DestinationPath) if health.Warning != "" { data["BackupDestWarning"] = health.Warning data["BackupDestWarningSeverity"] = health.Severity } } } // Memory info for deploy page (only for non-deployed apps) if !alreadyDeployed { memInfo := map[string]interface{}{"Available": false} totalMB, memErr := system.GetTotalMemoryMB() if memErr == nil { reservedMB := s.cfg.System.ReservedMemoryMB usableMB := totalMB - reservedMB committedReqMB, committedLimitMB := s.stackMgr.CommittedMemory() newReqMB := stacks.ParseMemoryMB(meta.Resources.MemRequest) newLimitMB := stacks.ParseMemoryMB(meta.Resources.MemLimit) afterReqMB := committedReqMB + newReqMB afterLimitMB := committedLimitMB + newLimitMB percent := 0 if usableMB > 0 { percent = afterReqMB * 100 / usableMB } committedPercent := 0 if usableMB > 0 { committedPercent = committedReqMB * 100 / usableMB } memInfo["Available"] = true memInfo["TotalMB"] = totalMB memInfo["ReservedMB"] = reservedMB memInfo["UsableMB"] = usableMB memInfo["CommittedMB"] = committedReqMB memInfo["NewRequestMB"] = newReqMB memInfo["AfterMB"] = afterReqMB memInfo["Percent"] = percent memInfo["CommittedPercent"] = committedPercent memInfo["Blocked"] = newReqMB > 0 && afterReqMB > usableMB memInfo["OvercommitWarn"] = newLimitMB > 0 && afterLimitMB > totalMB } data["MemoryInfo"] = memInfo } // Flash messages from cross-drive backup save redirect if flash := r.URL.Query().Get("flash"); flash != "" { data["FlashSuccess"] = flash } if flashErr := r.URL.Query().Get("flash_error"); flashErr != "" { data["FlashError"] = flashErr } s.executeTemplate(w, r, "deploy", data) } func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug string) { var found *stacks.Stack for _, stack := range s.stackMgr.GetStacks() { if stack.Meta.Slug == slug { found = &stack break } } if found == nil { http.NotFound(w, r) return } // Load current optional config values from app.yaml currentValues := make(map[string]string) if appCfg := s.stackMgr.LoadAppConfigByName(found.Name); appCfg != nil { for k, v := range appCfg.Env { currentValues[k] = v } } // Determine effective subdomain (stored env > metadata fallback) effectiveSubdomain := found.Meta.Subdomain if sd, ok := currentValues["SUBDOMAIN"]; ok && sd != "" { effectiveSubdomain = sd } data := s.baseData("stacks", found.Meta.DisplayName) data["Stack"] = found data["Meta"] = found.Meta data["AppInfo"] = found.Meta.AppInfo data["OptionalConfig"] = found.Meta.OptionalConfig data["CurrentValues"] = currentValues data["HasAppInfo"] = found.Meta.HasAppInfo() data["HasOptionalConfig"] = found.Meta.HasOptionalConfig() data["EffectiveSubdomain"] = effectiveSubdomain s.executeTemplate(w, r, "app_info", data) } func (s *Server) monitoringHandler(w http.ResponseWriter, r *http.Request) { data := s.baseData("monitoring", "Rendszermonitor") data["SystemInfo"] = system.GetInfo(s.primaryHDDPath(), s.cpuCollector) data["StorageBars"] = s.buildStorageBars() if s.alertManager != nil { data["Alerts"] = s.alertManager.GetAlerts() data["DiskWarnings"] = s.alertManager.GetInlineAlerts("monitoring") } // Hub connection status section data["HubEnabled"] = s.cfg.Hub.Enabled && s.cfg.Hub.URL != "" data["HubURL"] = s.cfg.Hub.URL data["CustomerID"] = s.cfg.Customer.ID if s.hubPushStatusFn != nil { ps := s.hubPushStatusFn() data["HubLastAttempt"] = ps.LastAttempt data["HubLastSuccess"] = ps.LastSuccess data["HubLastError"] = ps.LastError data["HubConsecutiveFailures"] = ps.Consecutive // Connected if last success was within 2x the push interval (or 30min default) connected := !ps.LastSuccess.IsZero() && time.Since(ps.LastSuccess) < 30*time.Minute data["HubConnected"] = connected } // Legacy ping status section (still shown for backward compat during transition) data["MonitoringEnabled"] = s.cfg.Monitoring.Enabled if s.cfg.Monitoring.Enabled { pings := []map[string]interface{}{ {"Label": "Eletjel (Heartbeat)", "Icon": "heartbeat", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.Heartbeat), "Schedule": "5 percenkent"}, {"Label": "Rendszer allapot", "Icon": "system", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.SystemHealth), "Schedule": "5 percenkent"}, {"Label": "Adatbazis mentes", "Icon": "db", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.DBDump), "Schedule": "Naponta " + s.cfg.Backup.DBDumpSchedule}, {"Label": "Biztonsagi mentes", "Icon": "backup", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.Backup), "Schedule": "Naponta " + s.cfg.Backup.ResticSchedule}, {"Label": "Mentes integritas", "Icon": "integrity", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.BackupIntegrity), "Schedule": "Hetente (vasarnap)"}, } allConfigured := true for _, p := range pings { if !p["Configured"].(bool) { allConfigured = false break } } data["PingStatus"] = pings data["AllPingsConfigured"] = allConfigured } s.executeTemplate(w, r, "monitoring", data) } // isPingConfigured returns true if a healthcheck ping UUID is non-empty and not a placeholder. func isPingConfigured(uuid string) bool { return uuid != "" && !strings.HasPrefix(uuid, "CHANGEME") } func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) { data := s.baseData("backups", "Biztonsági mentés") // System info for storage overview bars data["SystemInfo"] = system.GetInfo(s.primaryHDDPath(), s.cpuCollector) data["StorageBars"] = s.buildStorageBars() if s.backupMgr != nil { nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule) nextBackup := scheduler.NextDailyRun(s.cfg.Backup.ResticSchedule) fullStatus := s.backupMgr.GetFullStatus(nextDBDump, nextBackup) // Pass flash messages from query params (set by redirect handlers) if flash := r.URL.Query().Get("flash"); flash != "" { fullStatus.FlashSuccess = flash } if flashErr := r.URL.Query().Get("flash_error"); flashErr != "" { fullStatus.FlashError = flashErr } // Enrich AppDataInfo with storage labels storagePaths := s.settings.GetStoragePaths() for i := range fullStatus.AppDataInfo { app := &fullStatus.AppDataInfo[i] if len(app.HDDPaths) > 0 { hddPath := app.HDDPaths[0].HostPath // Match HDD path prefix against registered storage paths for _, sp := range storagePaths { if strings.HasPrefix(hddPath, sp.Path) { app.StorageLabel = sp.Label break } } } } // Build cross-drive summary crossConfigs := s.settings.GetAllCrossDriveConfigs() // Build label lookup for dest paths destLabels := make(map[string]string) for _, sp := range storagePaths { destLabels[sp.Path] = sp.Label } for _, app := range fullStatus.AppDataInfo { cfg, hasCfg := crossConfigs[app.StackName] if !hasCfg || cfg == nil { fullStatus.UnconfiguredApps = append(fullStatus.UnconfiguredApps, backup.CrossDriveSummaryItem{ StackName: app.StackName, DisplayName: app.DisplayName, }) continue } item := backup.CrossDriveSummaryItem{ StackName: app.StackName, DisplayName: app.DisplayName, Method: cfg.Method, DestPath: cfg.DestinationPath, DestLabel: destLabels[cfg.DestinationPath], Schedule: cfg.Schedule, LastStatus: cfg.LastStatus, SizeHuman: cfg.LastSizeHuman, } switch cfg.Method { case "rsync": item.MethodLabel = "rsync" case "restic": item.MethodLabel = "restic" default: item.MethodLabel = cfg.Method } switch cfg.Schedule { case "daily": item.ScheduleLabel = "Naponta" case "weekly": item.ScheduleLabel = "Hetente" default: item.ScheduleLabel = "Kézi" } if cfg.LastRun != "" { if t, err := time.Parse(time.RFC3339, cfg.LastRun); err == nil { item.LastRunShort = t.In(getTimezone()).Format("01-02 15:04") } } fullStatus.CrossDriveSummary = append(fullStatus.CrossDriveSummary, item) // Destination health warning (tiered validation) if cfg.Enabled && cfg.DestinationPath != "" { health := system.CheckBackupDestination(cfg.DestinationPath) if health.Warning != "" { prefix := "⚠️" if health.Severity == "critical" { prefix = "🔴" } fullStatus.CrossDriveWarnings = append(fullStatus.CrossDriveWarnings, 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 if pw, err := s.backupMgr.GetResticPassword(); err == nil { data["ResticPassword"] = pw } // Részletek section: DB dump total size var dbDumpTotalBytes int64 for _, f := range fullStatus.DumpFiles { dbDumpTotalBytes += f.Size } data["DBDumpTotalBytes"] = dbDumpTotalBytes // Részletek section: enrich per-drive repo stats with storage labels for i := range fullStatus.PerDriveRepoStats { for _, sp := range storagePaths { if strings.HasPrefix(fullStatus.PerDriveRepoStats[i].DrivePath, sp.Path) || fullStatus.PerDriveRepoStats[i].DrivePath == sp.Path { fullStatus.PerDriveRepoStats[i].DriveLabel = sp.Label break } } if fullStatus.PerDriveRepoStats[i].DriveLabel == "" { fullStatus.PerDriveRepoStats[i].DriveLabel = filepath.Base(fullStatus.PerDriveRepoStats[i].DrivePath) } } data["PerDriveRepoStats"] = fullStatus.PerDriveRepoStats // Részletek section: group Tier 2 items by destination drive tier2GroupMap := make(map[string]*Tier2DriveGroup) for _, item := range fullStatus.CrossDriveSummary { if item.DestPath == "" { continue } grp, exists := tier2GroupMap[item.DestPath] if !exists { grp = &Tier2DriveGroup{ DestPath: item.DestPath, DestLabel: item.DestLabel, } if grp.DestLabel == "" { grp.DestLabel = filepath.Base(item.DestPath) } tier2GroupMap[item.DestPath] = grp } grp.Items = append(grp.Items, item) } var tier2Groups []Tier2DriveGroup for _, grp := range tier2GroupMap { tier2Groups = append(tier2Groups, *grp) } data["Tier2DriveGroups"] = tier2Groups } else { data["Backup"] = nil } s.executeTemplate(w, r, "backups", data) } // Tier2DriveGroup holds grouped Tier 2 cross-drive backup items for one destination drive. type Tier2DriveGroup struct { DestPath string DestLabel string Items []backup.CrossDriveSummaryItem } // AppBackupRow holds per-tier backup information for one app on the backup page. type AppBackupRow struct { StackName string DisplayName string Status string // "green", "yellow", "red", "auto" StatusText string // short Hungarian tooltip // App characteristics HasHDDData bool HasDB bool StorageLabel string HDDSizeHuman string // What this app's backup contains (for display) // e.g., "DB + Konfiguráció + Adatok", "DB + Konfiguráció", "Konfiguráció" BackupContents string // Tier 1: Nightly backup (always exists) Tier1LastRun string // formatted time of last restic snapshot Tier1LastStatus string // "ok", "error", "" Tier1DBStatus string // "ok", "error", "" — separate DB dump status for warning // Tier 2: Cross-drive backup (configurable for all apps) Tier2Configured bool Tier2Dest string // destination label Tier2Schedule string // "Naponta", "Hetente" Tier2LastRun string Tier2LastStatus string // "ok", "error", "running", "" Tier2LastError string Tier2StatusBadge string // "Sikeres", "Hiba", "Fut...", "—" Tier2SizeHuman string // Drive disconnected — app's home drive is currently disconnected DriveDisconnected bool // Warnings accumulated for this app Warnings []string } // buildAppBackupRows constructs one AppBackupRow per deployed app for the backup page. func (s *Server) buildAppBackupRows( status *backup.FullBackupStatus, crossConfigs map[string]*settings.CrossDriveBackup, destLabels map[string]string, ) []AppBackupRow { loc := getTimezone() // Build DB stack lookup dbStacks := make(map[string]bool) for _, db := range status.DiscoveredDBs { dbStacks[db.StackName] = true } for _, f := range status.DumpFiles { dbStacks[f.StackName] = true } // Tier 1 timestamps (shared across all apps — single nightly job) tier1LastRun := "" tier1LastStatus := "" if status.LastBackup != nil { tier1LastRun = status.LastBackup.LastRun.In(loc).Format("01-02 15:04") if status.LastBackup.Success { tier1LastStatus = "ok" } else { tier1LastStatus = "error" } } tier1DBStatus := "" if status.LastDBDump != nil { if status.LastDBDump.Success { tier1DBStatus = "ok" } else { tier1DBStatus = "error" } } // Build disconnected paths set for drive-disconnected detection disconnectedPaths := make(map[string]bool) for _, dp := range s.settings.GetDisconnectedPaths() { disconnectedPaths[dp.Path] = true } var rows []AppBackupRow for _, app := range status.AppDataInfo { hasDB := dbStacks[app.StackName] || app.HasDBDump // Check if this app's home drive is disconnected driveDisconnected := false if app.HasHDDData && len(app.HDDPaths) > 0 { for dp := range disconnectedPaths { for _, hp := range app.HDDPaths { if strings.HasPrefix(hp.HostPath, dp+"/") || hp.HostPath == dp { driveDisconnected = true break } } if driveDisconnected { break } } } // Build backup contents label var parts []string if hasDB { parts = append(parts, "DB") } parts = append(parts, "Konfig") if app.HasHDDData { parts = append(parts, "Adatok") } contents := strings.Join(parts, " + ") row := AppBackupRow{ StackName: app.StackName, DisplayName: app.DisplayName, HasHDDData: app.HasHDDData, HasDB: hasDB, DriveDisconnected: driveDisconnected, StorageLabel: app.StorageLabel, HDDSizeHuman: app.HDDSizeHuman, BackupContents: contents, Tier1LastRun: tier1LastRun, Tier1LastStatus: tier1LastStatus, Tier1DBStatus: tier1DBStatus, } // Status dot — start as yellow (1 tier only) row.Status = "yellow" row.StatusText = "Csak helyi mentés (1 szint)" cfg, hasCfg := crossConfigs[app.StackName] if !hasCfg || cfg == nil || !cfg.Enabled { // Only Tier 1 — no second copy row.Tier2Configured = false } else { row.Tier2Configured = true row.Tier2Dest = destLabels[cfg.DestinationPath] if row.Tier2Dest == "" { row.Tier2Dest = cfg.DestinationPath } switch cfg.Schedule { case "daily": row.Tier2Schedule = "Naponta" case "weekly": row.Tier2Schedule = "Hetente" default: row.Tier2Schedule = cfg.Schedule } if cfg.LastRun != "" { if t, err := time.Parse(time.RFC3339, cfg.LastRun); err == nil { row.Tier2LastRun = t.In(loc).Format("01-02 15:04") } } row.Tier2LastStatus = cfg.LastStatus row.Tier2LastError = cfg.LastError row.Tier2SizeHuman = cfg.LastSizeHuman switch cfg.LastStatus { case "ok": row.Tier2StatusBadge = "Sikeres" row.Status = "green" row.StatusText = "Mentés rendben" case "error": row.Tier2StatusBadge = "Hiba" // Status stays yellow row.StatusText = "Utolsó mentés sikertelen" case "running": row.Tier2StatusBadge = "Fut..." default: row.Tier2StatusBadge = "—" // Tier2 configured but never run — stay yellow } // Destination health check — can downgrade green to yellow/red if cfg.DestinationPath != "" { if err := s.crossDriveRunner.ValidateDestination(cfg.DestinationPath); err != nil { if strings.Contains(err.Error(), "does not exist") || strings.Contains(err.Error(), "not writable") { row.Status = "red" row.StatusText = "Mentési cél nem elérhető" } else if row.Status != "red" { row.Status = "yellow" row.StatusText = "Figyelmeztetés" } row.Warnings = append(row.Warnings, err.Error()) } } } // DB dump failure warning (affects Tier 1 quality) if hasDB && tier1DBStatus == "error" { if 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} // Saves or updates the cross-drive backup configuration for an app. func (s *Server) settingsCrossBackupHandler(w http.ResponseWriter, r *http.Request, name string) { _ = r.ParseForm() enabled := r.FormValue("cross_drive_enabled") == "on" // Preserve existing runtime status fields and config when disabling existing := s.settings.GetCrossDriveConfig(name) var destPath, schedule string if enabled { destPath = r.FormValue("cross_drive_dest") schedule = r.FormValue("cross_drive_schedule") if schedule != "daily" && schedule != "weekly" { schedule = "daily" } } else if existing != nil { // Preserve existing settings when disabling destPath = existing.DestinationPath schedule = existing.Schedule } var cfg *settings.CrossDriveBackup if destPath != "" || existing != nil { cfg = &settings.CrossDriveBackup{ Enabled: enabled, Method: "rsync", DestinationPath: destPath, Schedule: schedule, } if existing != nil { cfg.LastRun = existing.LastRun cfg.LastStatus = existing.LastStatus cfg.LastError = existing.LastError cfg.LastDuration = existing.LastDuration cfg.LastSizeHuman = existing.LastSizeHuman } } if err := s.settings.SetCrossDriveConfig(name, cfg); err != nil { s.logger.Printf("[ERROR] Failed to save cross-drive config for %s: %v", name, err) http.Redirect(w, r, "/stacks/"+name+"/deploy?flash_error=Hiba+a+ment%C3%A9si+be%C3%A1ll%C3%ADt%C3%A1s+ment%C3%A9sakor", http.StatusFound) return } s.logger.Printf("[INFO] Cross-drive backup config saved for %s: dest=%s schedule=%s enabled=%v", name, destPath, schedule, enabled) http.Redirect(w, r, "/stacks/"+name+"/deploy?flash=Ment%C3%A9si+be%C3%A1ll%C3%ADt%C3%A1s+mentve.", http.StatusFound) } func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() stackName := r.FormValue("stack_name") snapshotID := r.FormValue("snapshot_id") if stackName == "" || snapshotID == "" { http.Redirect(w, r, "/backups?flash_error=Hi%C3%A1nyz%C3%B3+param%C3%A9terek", http.StatusFound) return } if s.backupMgr == nil { http.Redirect(w, r, "/backups?flash_error=Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva", http.StatusFound) return } s.logger.Printf("[WARN] Restore requested: stack=%s, snapshot=%s from %s", stackName, snapshotID, r.RemoteAddr) if err := s.backupMgr.RestoreApp(stackName, snapshotID); err != nil { s.logger.Printf("[ERROR] Restore failed: %v", err) errMsg := url.QueryEscape("Visszaállítás sikertelen: " + err.Error()) http.Redirect(w, r, "/backups?flash_error="+errMsg, http.StatusFound) return } msg := url.QueryEscape(stackName + " visszaállítva (" + snapshotID + ").") http.Redirect(w, r, "/backups?flash="+msg, http.StatusFound) } func (s *Server) settingsData() map[string]interface{} { data := s.baseData("settings", "Beállítások") data["CustomerID"] = s.cfg.Customer.ID data["CustomerDomain"] = s.cfg.Customer.Domain data["GitRepoURL"] = s.cfg.Git.RepoURL data["GitSyncInterval"] = s.cfg.Git.SyncInterval data["BackupEnabled"] = s.cfg.Backup.Enabled data["DBDumpSchedule"] = s.cfg.Backup.DBDumpSchedule data["ResticSchedule"] = s.cfg.Backup.ResticSchedule data["MonitoringEnabled"] = s.cfg.Monitoring.Enabled data["HealthchecksBase"] = s.cfg.Monitoring.HealthchecksBase data["HubEnabled"] = s.cfg.Hub.Enabled // Self-update status data["SelfUpdateEnabled"] = s.cfg.SelfUpdate.Enabled if s.updater != nil { status := s.updater.GetStatus() data["UpdateRunning"] = status.Running if status.LastCheck != nil { data["UpdateAvailable"] = status.LastCheck.UpdateAvailable data["LatestVersion"] = status.LastCheck.LatestVersion data["LastCheckTime"] = status.LastCheck.CheckedAt data["LastCheckError"] = status.LastCheck.Error } if status.LastState != nil { data["LastUpdateState"] = status.LastState } data["AutoUpdateEnabled"] = s.cfg.SelfUpdate.AutoUpdate data["AutoUpdateTime"] = s.cfg.SelfUpdate.AutoUpdateTime } data["NotificationPrefs"] = s.settings.GetNotificationPrefs() // Storage paths with display data storagePaths := s.settings.GetStoragePaths() connectedCount := 0 for _, sp := range storagePaths { if !sp.Disconnected && !sp.Decommissioned { connectedCount++ } } var storageViews []StoragePathView for _, sp := range storagePaths { view := StoragePathView{ StoragePath: sp, StoppedApps: sp.StoppedStacks, HasOtherPaths: connectedCount > 1, } if sp.Disconnected { // Skip I/O calls on disconnected drives — they'd hang or fail view.IsMounted = false } else if sp.Decommissioned { view.IsMounted = false view.MigratedToLabel = s.settings.GetStorageLabel(sp.MigratedTo) } else { view.IsMounted = system.IsMountPoint(sp.Path) view.AppDetails = s.appDetailsForPath(sp.Path) view.FSInfo = system.GetFSInfo(sp.Path) view.AppCount = len(view.AppDetails) if di := system.GetDiskUsage(sp.Path); di != nil { view.DiskInfo = di } // Detect USB for safe disconnect button if view.FSInfo != nil && view.FSInfo.Device != "" { view.IsUSB = system.IsUSBDevice(view.FSInfo.Device) } } storageViews = append(storageViews, view) } data["StoragePaths"] = storageViews // Recovery info for emergency section data["RetrievalPassword"] = s.settings.GetRetrievalPassword() data["HubURL"] = s.cfg.Hub.URL data["SupportEmail"] = "support@felhom.eu" data["SupportURL"] = "https://felhom.eu/kapcsolat" return data } func (s *Server) settingsHandler(w http.ResponseWriter, r *http.Request) { data := s.settingsData() if msg := r.URL.Query().Get("storage_msg"); msg == "success" { data["StorageSuccess"] = r.URL.Query().Get("storage_detail") } s.executeTemplate(w, r, "settings", data) } func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() currentPassword := r.FormValue("current_password") newPassword := r.FormValue("new_password") confirmPassword := r.FormValue("confirm_password") data := s.settingsData() // Validate current password effectiveHash := s.effectivePasswordHash() if err := bcrypt.CompareHashAndPassword([]byte(effectiveHash), []byte(currentPassword)); err != nil { data["PasswordError"] = "Hibás jelenlegi jelszó" s.executeTemplate(w, r, "settings", data) return } // Validate new password length if len(newPassword) < 8 { data["PasswordError"] = "A jelszónak legalább 8 karakter hosszúnak kell lennie" s.executeTemplate(w, r, "settings", data) return } // Validate passwords match if newPassword != confirmPassword { data["PasswordError"] = "A két jelszó nem egyezik" s.executeTemplate(w, r, "settings", data) return } // Generate bcrypt hash hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), 10) if err != nil { s.logger.Printf("[ERROR] Failed to hash new password: %v", err) data["PasswordError"] = "Belső hiba a jelszó mentésekor" s.executeTemplate(w, r, "settings", data) return } // Save to settings.json if err := s.settings.SetPasswordHash(string(hash)); err != nil { s.logger.Printf("[ERROR] Failed to save password to settings.json: %v", err) data["PasswordError"] = "Belső hiba a jelszó mentésekor" s.executeTemplate(w, r, "settings", data) return } s.logger.Printf("[INFO] Password changed via settings page from %s", r.RemoteAddr) // Invalidate all sessions (force re-login) s.invalidateAllSessions() // Redirect to login with flash message flash := url.QueryEscape("Jelszó sikeresen módosítva. Kérjük, jelentkezzen be az új jelszóval.") http.Redirect(w, r, "/login?flash="+flash, http.StatusFound) } func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() email := strings.TrimSpace(r.FormValue("notification_email")) cooldownStr := r.FormValue("cooldown_hours") cooldownHours := 6 if cooldownStr != "" { if n, err := fmt.Sscanf(cooldownStr, "%d", &cooldownHours); n != 1 || err != nil { cooldownHours = 6 } } if cooldownHours < 1 { cooldownHours = 1 } if cooldownHours > 168 { cooldownHours = 168 } // Collect enabled events from checkboxes var enabledEvents []string // Single-event checkboxes for _, evt := range []string{ "backup_failed", "db_dump_failed", "backup_integrity_failed", "crossdrive_failed", "storage_disconnected", "node_down", "health_critical", "storage_reconnected", "health_recovered", } { if r.FormValue("event_"+evt) == "on" { enabledEvents = append(enabledEvents, evt) } } // Compound toggles: one checkbox → two event types if r.FormValue("event_disk_alerts") == "on" { enabledEvents = append(enabledEvents, "disk_warning", "disk_critical") } if r.FormValue("event_expected_missed") == "on" { enabledEvents = append(enabledEvents, "expected_backup_missed", "expected_dbdump_missed") } prefs := &settings.NotificationPrefs{ Email: email, EnabledEvents: enabledEvents, CooldownHours: cooldownHours, } if err := s.settings.SetNotificationPrefs(prefs); err != nil { s.logger.Printf("[ERROR] Failed to save notification prefs: %v", err) data := s.settingsData() data["NotificationError"] = "Hiba a beállítások mentésekor" s.executeTemplate(w, r, "settings", data) return } s.logger.Printf("[INFO] Notification preferences updated: email=%s, events=%v", email, enabledEvents) // Sync preferences to hub data := s.settingsData() if s.notifier != nil && s.notifier.IsEnabled() { if err := s.notifier.SyncPreferences(email, enabledEvents, cooldownHours); err != nil { s.logger.Printf("[WARN] Failed to sync preferences to hub: %v", err) data["NotificationSuccess"] = fmt.Sprintf("Értesítési beállítások mentve (helyi). A központi szinkronizálás sikertelen: %v", err) } else { data["NotificationSuccess"] = "Értesítési beállítások mentve." } } else { data["NotificationSuccess"] = "Értesítési beállítások mentve." } s.executeTemplate(w, r, "settings", data) } func (s *Server) settingsNotificationsTestHandler(w http.ResponseWriter, r *http.Request) { data := s.settingsData() if s.notifier == nil { data["NotificationError"] = "Az értesítések nincsenek bekapcsolva" s.executeTemplate(w, r, "settings", data) return } err := s.notifier.SendTest() if err != nil { s.logger.Printf("[ERROR] Test notification failed: %v", err) data["NotificationError"] = fmt.Sprintf("Teszt email küldése sikertelen: %v", err) s.executeTemplate(w, r, "settings", data) return } data["NotificationSuccess"] = "Teszt email elküldve." s.executeTemplate(w, r, "settings", data) } // --- Storage path management handlers --- func (s *Server) countAppsUsingPath(storagePath string) int { count := 0 for _, stack := range s.stackMgr.GetStacks() { if !stack.Deployed { continue } if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil { if appCfg.Env["HDD_PATH"] == storagePath { count++ } } } return count } func (s *Server) appsUsingPath(storagePath string) []string { var names []string for _, stack := range s.stackMgr.GetStacks() { if !stack.Deployed { continue } if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil { if appCfg.Env["HDD_PATH"] == storagePath { names = append(names, stack.Meta.DisplayName) } } } return names } func (s *Server) appDetailsForPath(storagePath string) []StorageAppDetail { var details []StorageAppDetail for _, stack := range s.stackMgr.GetStacks() { if !stack.Deployed { continue } appCfg := s.stackMgr.LoadAppConfigByName(stack.Name) if appCfg == nil { continue } hddPath := appCfg.Env["HDD_PATH"] if hddPath != storagePath { continue } detail := StorageAppDetail{ Name: stack.Meta.DisplayName, Stack: stack.Meta.Slug, } // Try to get data size from the storage subdirectory appDataDir := backup.AppDataDir(storagePath, stack.Name) if fi, err := os.Stat(appDataDir); err == nil && fi.IsDir() { detail.SizeHuman = dirSizeHuman(appDataDir) } details = append(details, detail) } return details } // dirSizeHuman returns a human-readable size for a directory. func dirSizeHuman(path string) string { var total int64 filepath.Walk(path, func(_ string, info os.FileInfo, err error) error { if err != nil || info.IsDir() { return nil } total += info.Size() return nil }) const ( KB = 1024 MB = KB * 1024 GB = MB * 1024 ) switch { case total >= GB: return fmt.Sprintf("%.1f GB", float64(total)/float64(GB)) case total >= MB: return fmt.Sprintf("%.1f MB", float64(total)/float64(MB)) case total >= KB: return fmt.Sprintf("%.1f KB", float64(total)/float64(KB)) default: return fmt.Sprintf("%d B", total) } } func formatFreeSpace(gb float64) string { if gb >= 1000 { return fmt.Sprintf("%.1f TB", gb/1024) } return fmt.Sprintf("%.1f GB", gb) } func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() path := filepath.Clean(r.FormValue("storage_path")) label := strings.TrimSpace(r.FormValue("storage_label")) isDefault := r.FormValue("storage_default") == "true" if label == "" { label = settings.InferStorageLabel(path) } data := s.settingsData() // 1. Exists and is directory fi, err := os.Stat(path) if err != nil || !fi.IsDir() { data["StorageError"] = "Az útvonal nem létezik vagy nem mappa." s.executeTemplate(w, r, "settings", data) return } // 2. Is mount point if !system.IsMountPoint(path) { data["StorageError"] = "Ez az útvonal nem külön csatlakoztatott meghajtó. Adatok az SSD-re kerülnének!" s.executeTemplate(w, r, "settings", data) return } // 3. Writable if !system.IsWritable(path) { data["StorageError"] = "Az útvonal nem írható." s.executeTemplate(w, r, "settings", data) return } // 4. No overlap with existing paths for _, existing := range s.settings.GetStoragePaths() { if system.PathsOverlap(path, existing.Path) { data["StorageError"] = fmt.Sprintf("Az útvonal átfedi a már regisztrált %s útvonalat.", existing.Path) s.executeTemplate(w, r, "settings", data) return } } // 5. Soft warning if not under /mnt/ if !strings.HasPrefix(path, "/mnt/") { s.logger.Printf("[WARN] Storage path %s is not under /mnt/ — unusual but allowed", path) } sp := settings.StoragePath{ Path: path, Label: label, IsDefault: isDefault, Schedulable: true, AddedAt: time.Now().UTC().Format(time.RFC3339), } if err := s.settings.AddStoragePath(sp); err != nil { s.logger.Printf("[ERROR] Failed to add storage path: %v", err) data["StorageError"] = "Hiba a mentés során." s.executeTemplate(w, r, "settings", data) return } s.logger.Printf("[INFO] Storage path added: %s (%s)", path, label) go s.SyncFileBrowserMounts() http.Redirect(w, r, "/settings?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló sikeresen hozzáadva: "+path), http.StatusFound) } func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() path := r.FormValue("storage_path") data := s.settingsData() // Check: apps using this path apps := s.appsUsingPath(path) if len(apps) > 0 { data["StorageError"] = fmt.Sprintf("Nem törölhető: az alábbi alkalmazások használják: %s", strings.Join(apps, ", ")) s.executeTemplate(w, r, "settings", data) return } // Check: cannot remove default for _, sp := range s.settings.GetStoragePaths() { if sp.Path == path && sp.IsDefault { data["StorageError"] = "Az alapértelmezett adattároló nem törölhető." s.executeTemplate(w, r, "settings", data) return } } // Check: last path if len(s.settings.GetStoragePaths()) <= 1 { data["StorageError"] = "Az utolsó adattároló nem törölhető." s.executeTemplate(w, r, "settings", data) return } if err := s.settings.RemoveStoragePath(path); err != nil { data["StorageError"] = "Hiba a törlés során." s.executeTemplate(w, r, "settings", data) return } s.logger.Printf("[INFO] Storage path removed: %s", path) // Sync FileBrowser mounts after storage path removal go s.SyncFileBrowserMounts() http.Redirect(w, r, "/settings?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló eltávolítva: "+path), http.StatusFound) } func (s *Server) settingsStorageDefaultHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() path := r.FormValue("storage_path") if err := s.settings.SetDefaultStoragePath(path); err != nil { s.logger.Printf("[ERROR] Failed to set default storage path: %v", err) http.Redirect(w, r, "/settings", http.StatusFound) return } http.Redirect(w, r, "/settings?storage_msg=success&storage_detail="+url.QueryEscape("Alapértelmezett adattároló beállítva: "+path), http.StatusFound) } func (s *Server) settingsStorageSchedulableHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() path := r.FormValue("storage_path") schedulable := r.FormValue("schedulable") == "true" if err := s.settings.SetSchedulable(path, schedulable); err != nil { s.logger.Printf("[ERROR] Failed to update schedulable: %v", err) http.Redirect(w, r, "/settings", http.StatusFound) return } http.Redirect(w, r, "/settings?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló állapot módosítva: "+path), http.StatusFound) } func (s *Server) settingsStorageLabelHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() path := r.FormValue("storage_path") label := strings.TrimSpace(r.FormValue("storage_label")) if label == "" || len(label) > 50 { data := s.settingsData() data["StorageError"] = "A megnevezés nem lehet üres és legfeljebb 50 karakter." s.executeTemplate(w, r, "settings", data) return } if err := s.settings.SetStorageLabel(path, label); err != nil { s.logger.Printf("[ERROR] Failed to set storage label: %v", err) data := s.settingsData() data["StorageError"] = "Hiba a megnevezés mentésekor." s.executeTemplate(w, r, "settings", data) return } s.logger.Printf("[INFO] Storage label updated: %s → %q", path, label) http.Redirect(w, r, "/settings?storage_msg=success&storage_detail="+url.QueryEscape("Megnevezés módosítva: "+label), http.StatusFound) } // SyncFileBrowserMounts regenerates FileBrowser's docker-compose.yml and config.yaml // with volume mounts and sources for all registered storage paths, then recreates the container. func (s *Server) SyncFileBrowserMounts() { stackDir := "/opt/docker/stacks/filebrowser" composePath := stackDir + "/docker-compose.yml" // Check if FileBrowser stack exists if _, err := os.Stat(composePath); os.IsNotExist(err) { s.logger.Printf("[WARN] FileBrowser stack not found at %s — skipping mount sync", composePath) return } // Get all active storage paths paths := s.settings.GetStoragePaths() // Use domain from controller config domain := s.cfg.Customer.Domain if domain == "" { s.logger.Printf("[WARN] Cannot sync FileBrowser mounts — customer domain not configured") return } // Build volume mount lines var storageMounts []string for _, sp := range paths { mountName := filepath.Base(sp.Path) // "/mnt/hdd_1" → "hdd_1" line := fmt.Sprintf(" - %s:/srv/%s", sp.Path, mountName) storageMounts = append(storageMounts, line) } // Generate and write config.yaml (sources + sidebar entries per drive) configPath := stackDir + "/config.yaml" fbConfig := generateFileBrowserConfig(paths) if err := os.WriteFile(configPath, []byte(fbConfig), 0644); err != nil { s.logger.Printf("[ERROR] Failed to write FileBrowser config: %v", err) return } // Generate and write compose (includes config.yaml mount) compose := generateFileBrowserCompose(domain, storageMounts) if err := os.WriteFile(composePath, []byte(compose), 0644); err != nil { s.logger.Printf("[ERROR] Failed to write FileBrowser compose: %v", err) return } // Recreate container — H16: use 60s timeout to prevent hanging indefinitely. ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() cmd := exec.CommandContext(ctx, "docker", "compose", "up", "-d", "--remove-orphans") cmd.Dir = stackDir if out, err := cmd.CombinedOutput(); err != nil { s.logger.Printf("[ERROR] Failed to recreate FileBrowser: %s — %v", string(out), err) } else { s.logger.Printf("[INFO] FileBrowser mounts synced — %d storage path(s), config updated", len(paths)) } } // generateFileBrowserCompose returns a FileBrowser docker-compose.yml string // with the given domain and storage volume mount lines. func generateFileBrowserCompose(domain string, storageMounts []string) string { storageSection := "" if len(storageMounts) > 0 { storageSection = "\n # Storage paths (auto-generated by felhom-controller)\n" + strings.Join(storageMounts, "\n") } return fmt.Sprintf(`# FileBrowser Quantum — Infrastructure file manager # Domain: files.%s # Deployed by docker-setup.sh — managed by felhom-controller # WARNING: Volume mounts are auto-generated. Manual edits will be overwritten. services: filebrowser: image: gtstef/filebrowser:latest container_name: filebrowser restart: unless-stopped environment: - TZ=Europe/Budapest volumes: - filebrowser_data:/home/filebrowser/data - ./config.yaml:/home/filebrowser/config.yaml:ro%s networks: - traefik-public deploy: resources: limits: memory: 256M healthcheck: test: ["CMD", "wget", "--spider", "-q", "http://localhost:80/"] interval: 30s timeout: 5s retries: 3 start_period: 15s labels: - "traefik.enable=true" - "traefik.http.routers.filebrowser.rule=Host(`+"`"+`files.%s`+"`"+`)" - "traefik.http.routers.filebrowser.entrypoints=websecure" - "traefik.http.routers.filebrowser.tls=true" - "traefik.http.services.filebrowser.loadbalancer.server.port=80" - "traefik.docker.network=traefik-public" volumes: filebrowser_data: networks: traefik-public: external: true `, domain, storageSection, domain) } // generateFileBrowserConfig returns a FileBrowser Quantum config.yaml with // a separate source per registered storage path. Each source appears as a // named sidebar entry in the FileBrowser UI. func generateFileBrowserConfig(paths []settings.StoragePath) string { var sources string if len(paths) == 0 { sources = ` - path: "/srv" ` } else { for _, sp := range paths { mountName := filepath.Base(sp.Path) label := sp.Label if label == "" { label = mountName } sources += fmt.Sprintf(" - path: \"/srv/%s\"\n name: %q\n config:\n defaultEnabled: true\n", mountName, label) } } return fmt.Sprintf(`# FileBrowser Quantum — managed by felhom-controller # WARNING: This file is auto-generated. Manual edits will be overwritten. server: port: 80 baseURL: "/" database: "/home/filebrowser/data/database.db" logging: - levels: "info|warning|error" sources: %suserDefaults: stickySidebar: true darkMode: true viewMode: "normal" showHidden: false dateFormat: false gallerySize: 3 themeColor: "var(--blue)" preview: disableHideSidebar: false highQuality: true image: true video: true motionVideoPreview: true office: true popup: true autoplayMedia: true folder: true permissions: api: false admin: false modify: false share: false realtime: false delete: false create: false download: true `, sources) }