Files
deploy-felhom-compose/controller/internal/web/handlers.go
T
admin c929948f27 feat: Docker volume backup, Tier 2 restore, restore dropdown fixes (v0.33.0)
- Add Docker named volume backup to Tier 1 (dump to tar, include in restic)
  and Tier 2 (copy tars to rsync mirror _volumes/ dir)
- Fix volume name resolution: use project-prefixed names (mealie_mealie_data)
- Fix double Tier 1 in restore dropdown: filter snapshots by app's home drive
- Add Tier 2 restore: RestoreAppFromTier2() restores from rsync mirror
- Show Tier 2 entry in restore dropdown when cross-drive backup succeeded
- Add .fab import link in restore section
- Volume-aware restore type banners and backup content labels

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 21:43:02 +01:00

1882 lines
59 KiB
Go

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/crypto"
"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"
)
// protectedStackSubdomains maps programmatically managed protected stacks
// to their well-known subdomains (these stacks have no .felhom.yml or app.yaml).
var protectedStackSubdomains = map[string]string{
"filebrowser": "files",
}
// 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
}
// Build subdomain map for "Megnyitás" buttons
subdomains := make(map[string]string)
for _, stack := range deployedStacks {
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
}
}
}
if stack.Meta.Subdomain != "" {
subdomains[stack.Name] = stack.Meta.Subdomain
} else if sd, ok := protectedStackSubdomains[stack.Name]; ok {
subdomains[stack.Name] = sd
}
}
data["Subdomains"] = subdomains
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 > well-known 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
}
}
}
if stack.Meta.Subdomain != "" {
subdomains[stack.Name] = stack.Meta.Subdomain
} else if sd, ok := protectedStackSubdomains[stack.Name]; ok {
subdomains[stack.Name] = sd
}
}
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) {
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] deployHandler: stack=%s method=%s", name, r.Method)
}
meta, appCfg, err := s.stackMgr.GetDeployFields(name)
if err != nil {
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] deployHandler: stack=%s not found: %v", name, err)
}
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)
var decryptedEnv map[string]string
if appCfg != nil {
decryptedEnv = crypto.DecryptMap(s.encKey, appCfg.Env)
}
if alreadyDeployed && appCfg != nil {
for _, f := range meta.AutoGeneratedFields() {
if val, ok := decryptedEnv[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 (decrypted) so fields show current values
if alreadyDeployed && decryptedEnv != nil {
data["DeployedFieldValues"] = decryptedEnv
}
// 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
// Effective subdomain for "Megnyitás" button
if alreadyDeployed && appCfg != nil {
if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd != "" {
data["EffectiveSubdomain"] = sd
}
}
// 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
}
}
// App-to-app integrations
if meta.HasIntegrations() && s.integrationMgr != nil {
data["HasIntegrations"] = true
data["Integrations"] = s.integrationMgr.ListForProvider(meta.Slug)
}
// Geo-restriction per-app data
geo := s.settings.GetGeoRestriction()
if geo != nil && geo.Enabled && s.cfg.Infrastructure.CFAPIToken != "" {
data["GeoGlobalEnabled"] = true
data["GeoGlobalCountries"] = geo.AllowedCountries
if ov, ok := geo.AppOverrides[name]; ok {
data["GeoAppOverride"] = true
data["GeoAppOverrideCountries"] = ov.AllowedCountries
} else {
data["GeoAppOverrideCountries"] = []string{}
}
} else {
data["GeoGlobalCountries"] = []string{}
data["GeoAppOverrideCountries"] = []string{}
}
// Optional config (metadata providers, etc.)
if meta.HasOptionalConfig() {
data["HasOptionalConfig"] = true
data["OptionalConfig"] = meta.OptionalConfig
optValues := make(map[string]string)
if decryptedEnv != nil {
for _, group := range meta.OptionalConfig {
for _, field := range group.Fields {
if val, ok := decryptedEnv[field.EnvVar]; ok {
optValues[field.EnvVar] = val
}
}
}
}
data["CurrentValues"] = optValues
}
}
// Memory info for deploy page (only for non-deployed apps)
if !alreadyDeployed {
memInfo := map[string]interface{}{"Available": false}
totalMB, usedMB, memErr := system.GetMemoryMB()
if memErr == nil {
reservedMB := s.cfg.System.ReservedMemoryMB
usableMB := totalMB - reservedMB
newReqMB := stacks.ParseMemoryMB(meta.Resources.MemRequest)
afterMB := usedMB + newReqMB
percent := 0
if usableMB > 0 {
percent = afterMB * 100 / usableMB
}
usedPercent := 0
if usableMB > 0 {
usedPercent = usedMB * 100 / usableMB
}
// Overcommit warning still uses declared limits
_, committedLimitMB := s.stackMgr.CommittedMemory()
newLimitMB := stacks.ParseMemoryMB(meta.Resources.MemLimit)
afterLimitMB := committedLimitMB + newLimitMB
memInfo["Available"] = true
memInfo["TotalMB"] = totalMB
memInfo["ReservedMB"] = reservedMB
memInfo["UsableMB"] = usableMB
memInfo["UsedMB"] = usedMB
memInfo["NewRequestMB"] = newReqMB
memInfo["AfterMB"] = afterMB
memInfo["Percent"] = percent
memInfo["UsedPercent"] = usedPercent
memInfo["Blocked"] = newReqMB > 0 && afterMB > 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
}
// Determine effective subdomain (stored env > metadata fallback)
effectiveSubdomain := found.Meta.Subdomain
if appCfg := s.stackMgr.LoadAppConfigByName(found.Name); appCfg != nil {
if sd, ok := appCfg.Env["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["HasAppInfo"] = found.Meta.HasAppInfo()
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
// Tier2 destination drive is currently disconnected (backup paused, not failed)
Tier2DestDisconnected bool
// Tier2 destination drive is inactive (Schedulable=false, backup paused)
Tier2DestInactive 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
}
// Check if Tier2 destination drive is disconnected
if cfg.DestinationPath != "" {
for dp := range disconnectedPaths {
if cfg.DestinationPath == dp || strings.HasPrefix(cfg.DestinationPath, dp+"/") {
row.Tier2DestDisconnected = true
break
}
}
}
// Also treat as disconnected if dest was removed from storage entirely
if cfg.DestinationPath != "" && !row.Tier2DestDisconnected {
if !s.settings.IsStoragePathKnown(cfg.DestinationPath) {
row.Tier2DestDisconnected = true
}
}
// Check if Tier2 destination drive is inactive (not schedulable)
if cfg.DestinationPath != "" && !row.Tier2DestDisconnected {
if !s.settings.IsStoragePathSchedulable(cfg.DestinationPath) {
row.Tier2DestInactive = true
}
}
if row.Tier2DestDisconnected {
// Disconnected destination — treat as paused, not failed
row.Status = "yellow"
row.StatusText = "2. mentés szünetel — cél meghajtó leválasztva"
} else if row.Tier2DestInactive {
// Inactive destination — treat as paused
row.Status = "yellow"
row.StatusText = "2. mentés szünetel — cél meghajtó inaktív"
} else if cfg.DestinationPath != "" && s.crossDriveRunner != nil {
// Destination health check — can downgrade green to yellow/red
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()
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] settingsCrossBackupHandler: stack=%s from %s", name, r.RemoteAddr)
}
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
}
// Validate destination path against registered storage paths (H11 fix — matches API handler).
if enabled && destPath != "" {
registeredPaths := s.settings.GetStoragePaths()
validDest := false
for _, sp := range registeredPaths {
if destPath == sp.Path {
validDest = true
break
}
}
if !validDest {
s.logger.Printf("[WARN] [web] Cross-drive backup: rejected invalid dest path %q for %s", destPath, name)
http.Redirect(w, r, "/stacks/"+name+"/deploy?flash_error="+url.QueryEscape("Érvénytelen célútvonal: "+destPath), http.StatusFound)
return
}
}
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] [web] 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] [web] 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 s.isDebug() {
s.logger.Printf("[DEBUG] [web] backupRestoreHandler: stack=%s snapshot=%s from %s", stackName, snapshotID, r.RemoteAddr)
}
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] [web] Restore requested: stack=%s, snapshot=%s from %s", stackName, snapshotID, r.RemoteAddr)
start := time.Now()
var err error
if snapshotID == "tier2-rsync" {
err = s.backupMgr.RestoreAppFromTier2(stackName)
} else {
err = s.backupMgr.RestoreApp(stackName, snapshotID)
}
if err != nil {
s.logger.Printf("[ERROR] [web] Restore failed: %v", err)
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] backupRestoreHandler: stack=%s failed after %s", stackName, time.Since(start))
}
errMsg := url.QueryEscape("Visszaállítás sikertelen: " + err.Error())
http.Redirect(w, r, "/backups?flash_error="+errMsg, http.StatusFound)
return
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] backupRestoreHandler: stack=%s completed in %s", stackName, time.Since(start))
}
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"
// Geo-restriction data
data["CFConfigured"] = s.cfg.Infrastructure.CFAPIToken != ""
geo := s.settings.GetGeoRestriction()
if geo != nil {
data["GeoEnabled"] = geo.Enabled
data["GeoAllowedCountries"] = geo.AllowedCountries
data["GeoAppOverrides"] = geo.AppOverrides
data["GeoLastSync"] = geo.LastSync
data["GeoLastError"] = geo.LastSyncError
} else {
data["GeoEnabled"] = false
data["GeoAllowedCountries"] = []string{"HU"}
data["GeoAppOverrides"] = map[string]interface{}{}
}
// Deployed apps for per-app override selector
var deployedApps []map[string]string
for _, stack := range s.stackMgr.GetStacks() {
if !stack.Deployed || s.cfg.IsProtectedStack(stack.Name) {
continue
}
deployedApps = append(deployedApps, map[string]string{
"Name": stack.Name,
"Display": stack.Meta.DisplayName,
})
}
data["DeployedApps"] = deployedApps
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")
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] settingsPasswordHandler: password change attempt from %s", r.RemoteAddr)
}
data := s.settingsData()
// Validate current password
effectiveHash := s.effectivePasswordHash()
if err := bcrypt.CompareHashAndPassword([]byte(effectiveHash), []byte(currentPassword)); err != nil {
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] settingsPasswordHandler: current password mismatch from %s", r.RemoteAddr)
}
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] [web] 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] [web] 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] [web] 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()
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] settingsNotificationsHandler: updating notification prefs from %s", r.RemoteAddr)
}
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] [web] 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] [web] 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] [web] 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] [web] 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 s.isDebug() {
s.logger.Printf("[DEBUG] [web] settingsStorageAddHandler: path=%s label=%q default=%v from %s", path, label, isDefault, r.RemoteAddr)
}
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] [web] 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] [web] 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] [web] 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")
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] settingsStorageRemoveHandler: path=%s from %s", path, r.RemoteAddr)
}
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] [web] 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 s.isDebug() {
s.logger.Printf("[DEBUG] [web] settingsStorageDefaultHandler: path=%s from %s", path, r.RemoteAddr)
}
if err := s.settings.SetDefaultStoragePath(path); err != nil {
s.logger.Printf("[ERROR] [web] Failed to set default storage path: %v", err)
http.Redirect(w, r, "/settings", http.StatusFound)
return
}
s.logger.Printf("[INFO] [web] Default storage path set to %s", path)
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 s.isDebug() {
s.logger.Printf("[DEBUG] [web] settingsStorageSchedulableHandler: path=%s schedulable=%v from %s", path, schedulable, r.RemoteAddr)
}
if err := s.settings.SetSchedulable(path, schedulable); err != nil {
s.logger.Printf("[ERROR] [web] Failed to update schedulable: %v", err)
http.Redirect(w, r, "/settings", http.StatusFound)
return
}
s.logger.Printf("[INFO] [web] Storage schedulable updated: %s → %v", path, schedulable)
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 s.isDebug() {
s.logger.Printf("[DEBUG] [web] settingsStorageLabelHandler: path=%s label=%q from %s", path, label, r.RemoteAddr)
}
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] [web] 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] [web] 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() {
s.syncFileBrowserMounts(false)
}
// SyncFileBrowserMountsReset is like SyncFileBrowserMounts but resets the FileBrowser
// database when sources change. Use only after restore — normal operations should use
// SyncFileBrowserMounts to preserve user accounts, permissions, and share links.
func (s *Server) SyncFileBrowserMountsReset() {
s.syncFileBrowserMounts(true)
}
func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
// Prevent concurrent syncs — multiple callers can race on the same files (H5 fix).
s.fileBrowserMu.Lock()
defer s.fileBrowserMu.Unlock()
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] [web] 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] [web] 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)
// Detect if sources changed — if so, the database must be reset so
// FileBrowser picks up the new source list (user prefs cache old sources).
sourcesChanged := true
if oldConfig, err := os.ReadFile(configPath); err == nil {
sourcesChanged = string(oldConfig) != fbConfig
}
if err := os.WriteFile(configPath, []byte(fbConfig), 0644); err != nil {
s.logger.Printf("[ERROR] [web] Failed to write FileBrowser config: %v", err)
return
}
// Re-apply active integrations into config.yaml (before container restart)
if s.integrationMgr != nil {
s.integrationMgr.ReapplyConfigForTarget("filebrowser")
}
// 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] [web] Failed to write FileBrowser compose: %v", err)
return
}
// If sources changed and caller requested a DB reset (restore flow),
// nuke the data volume so FileBrowser re-reads config.yaml from scratch.
// Normal operations skip this to preserve user accounts, permissions, and share links.
if sourcesChanged && resetDBOnChange {
s.logger.Printf("[INFO] [web] FileBrowser sources changed — resetting database (restore mode)")
resetCtx, resetCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer resetCancel()
stop := exec.CommandContext(resetCtx, "docker", "compose", "down", "-v")
stop.Dir = stackDir
if out, err := stop.CombinedOutput(); err != nil {
s.logger.Printf("[WARN] [web] FileBrowser down -v: %s — %v", strings.TrimSpace(string(out)), err)
}
}
// 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", "--force-recreate", "--remove-orphans")
cmd.Dir = stackDir
if out, err := cmd.CombinedOutput(); err != nil {
s.logger.Printf("[ERROR] [web] Failed to recreate FileBrowser: %s — %v", string(out), err)
} else {
s.logger.Printf("[INFO] [web] 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
- FILEBROWSER_CONFIG=/home/filebrowser/config.yaml
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)
}