Files
deploy-felhom-compose/controller/internal/web/handlers.go
T
admin bdbe170a54 feat: storage watchdog — USB disconnect detection, auto-stop, safe eject, auto-reconnect (v0.17.0)
New storage watchdog monitors registered storage paths every 5s. On disconnect
(3 consecutive probe failures), auto-stops affected apps, lazy-unmounts stale
VFS entries, fires alerts/notifications/hub report. On reconnect (UUID detected),
auto-remounts via fstab, cleans stale restic locks, offers app restart.

Safe disconnect UI for USB drives: confirmation dialog, stop apps, sync, unmount.
Disconnected state visible across all pages (dashboard, settings, backups, monitoring)
with hatched red bars and badges. Backup guards skip disconnected drives.

22 files changed (1 new: monitor/watchdog.go), ~1500 lines added.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:42:26 +01:00

1572 lines
48 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/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() {
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)
}
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(),
}
if s.alertManager != nil {
data["Alerts"] = s.alertManager.GetAlerts()
}
return data
}
func (s *Server) dashboardHandler(w http.ResponseWriter, _ *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.render(w, "dashboard", data)
}
func (s *Server) stacksHandler(w http.ResponseWriter, _ *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
s.render(w, "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.render(w, "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 for already-deployed apps
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
}
}
}
data["AutoFieldValues"] = autoFieldValues
// 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.render(w, "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
}
}
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()
s.render(w, "app_info", data)
}
func (s *Server) monitoringHandler(w http.ResponseWriter, _ *http.Request) {
data := s.baseData("monitoring", "Rendszermonitor")
data["SystemInfo"] = system.GetInfo(s.primaryHDDPath(), s.cpuCollector)
data["StorageBars"] = s.buildStorageBars()
// On monitoring page, exclude the "pings-missing" alert since the detailed table is visible
if s.alertManager != nil {
data["Alerts"] = s.alertManager.GetAlerts("pings-missing")
data["DiskWarnings"] = s.alertManager.GetInlineAlerts("monitoring")
}
// Ping status section
data["MonitoringEnabled"] = s.cfg.Monitoring.Enabled
if s.cfg.Monitoring.Enabled {
pings := []map[string]interface{}{
{"Label": "Életjel (Heartbeat)", "Icon": "💓", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.Heartbeat), "Schedule": "5 percenként"},
{"Label": "Rendszer állapot", "Icon": "🖥️", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.SystemHealth), "Schedule": "5 percenként"},
{"Label": "Adatbázis mentés", "Icon": "🗄️", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.DBDump), "Schedule": "Naponta " + s.cfg.Backup.DBDumpSchedule},
{"Label": "Biztonsági mentés", "Icon": "💾", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.Backup), "Schedule": "Naponta " + s.cfg.Backup.ResticSchedule},
{"Label": "Mentés integritás", "Icon": "🔍", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.BackupIntegrity), "Schedule": "Hetente (vasárnap)"},
}
allConfigured := true
for _, p := range pings {
if !p["Configured"].(bool) {
allConfigured = false
break
}
}
data["PingStatus"] = pings
data["AllPingsConfigured"] = allConfigured
}
s.render(w, "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
}
switch item.Method {
case "restic":
grp.ResticItems = append(grp.ResticItems, item)
case "rsync":
grp.RsyncItems = append(grp.RsyncItems, item)
default:
grp.RsyncItems = append(grp.RsyncItems, item)
}
}
var tier2Groups []Tier2DriveGroup
for _, grp := range tier2GroupMap {
tier2Groups = append(tier2Groups, *grp)
}
data["Tier2DriveGroups"] = tier2Groups
} else {
data["Backup"] = nil
}
s.render(w, "backups", data)
}
// Tier2DriveGroup holds grouped Tier 2 cross-drive backup items for one destination drive.
type Tier2DriveGroup struct {
DestPath string
DestLabel string
ResticItems []backup.CrossDriveSummaryItem
RsyncItems []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
Tier2Method string // "rsync", "restic"
Tier2MethodLabel string // "rsync", "restic"
Tier2Dest string // destination label
Tier2Schedule string // "Naponta", "Hetente"
Tier2LastRun string
Tier2LastStatus string // "ok", "error", "running", ""
Tier2LastError string
Tier2StatusBadge string // "Sikeres", "Hiba", "Fut...", "—"
Tier2SizeHuman string
Tier2Browsable bool // true for rsync (plain files), false for restic
// 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.Tier2Method = cfg.Method
row.Tier2MethodLabel = cfg.Method // "rsync" or "restic"
row.Tier2Browsable = cfg.Method == "rsync"
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 method, destPath, schedule string
if enabled {
method = r.FormValue("cross_drive_method")
destPath = r.FormValue("cross_drive_dest")
schedule = r.FormValue("cross_drive_schedule")
// Validate method and schedule
if method != "rsync" && method != "restic" {
method = "rsync"
}
if schedule != "daily" && schedule != "weekly" {
schedule = "daily"
}
} else if existing != nil {
// Preserve existing settings when disabling
method = existing.Method
destPath = existing.DestinationPath
schedule = existing.Schedule
}
var cfg *settings.CrossDriveBackup
if destPath != "" || existing != nil {
cfg = &settings.CrossDriveBackup{
Enabled: enabled,
Method: method,
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: method=%s dest=%s schedule=%s enabled=%v",
name, method, 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()
var storageViews []StoragePathView
for _, sp := range storagePaths {
view := StoragePathView{
StoragePath: sp,
StoppedApps: sp.StoppedStacks,
}
if sp.Disconnected {
// Skip I/O calls on disconnected drives — they'd hang or fail
view.IsMounted = false
} 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
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.render(w, "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.render(w, "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.render(w, "settings", data)
return
}
// Validate passwords match
if newPassword != confirmPassword {
data["PasswordError"] = "A két jelszó nem egyezik"
s.render(w, "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.render(w, "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.render(w, "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
for _, evt := range []string{"disk_warning", "backup_failed", "update_available", "security_update"} {
if r.FormValue("event_"+evt) == "on" {
enabledEvents = append(enabledEvents, evt)
}
}
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.render(w, "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); 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.render(w, "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.render(w, "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.render(w, "settings", data)
return
}
data["NotificationSuccess"] = "Teszt email elküldve."
s.render(w, "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 := filepath.Join(storagePath, "storage", 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.render(w, "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.render(w, "settings", data)
return
}
// 3. Writable
if !system.IsWritable(path) {
data["StorageError"] = "Az útvonal nem írható."
s.render(w, "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.render(w, "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.render(w, "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.render(w, "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.render(w, "settings", data)
return
}
}
// Check: last path
if len(s.settings.GetStoragePaths()) <= 1 {
data["StorageError"] = "Az utolsó adattároló nem törölhető."
s.render(w, "settings", data)
return
}
if err := s.settings.RemoveStoragePath(path); err != nil {
data["StorageError"] = "Hiba a törlés során."
s.render(w, "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.render(w, "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.render(w, "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()
// Read domain from .env
envPath := stackDir + "/.env"
domain := ""
if data, err := os.ReadFile(envPath); err == nil {
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "DOMAIN=") {
domain = strings.TrimPrefix(line, "DOMAIN=")
break
}
}
}
if domain == "" {
s.logger.Printf("[WARN] Cannot read DOMAIN from FileBrowser .env — skipping mount sync")
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)
}