95c821deb2
Add detailed [DEBUG] logging to every controller module when logging.level is set to "debug". Each module with stateful debug uses SetDebug(bool) wired from main.go. Covers stacks, backup, cloudflare, integrations, system, monitor, settings, scheduler, web handlers, storage, metrics, API, selfupdate, and assets. Also includes the app export/import (.fab bundles) feature from v0.32.0 and its debug page integration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1829 lines
57 KiB
Go
1829 lines
57 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{}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Load current optional config values from app.yaml
|
|
currentValues := make(map[string]string)
|
|
if appCfg := s.stackMgr.LoadAppConfigByName(found.Name); appCfg != nil {
|
|
for k, v := range appCfg.Env {
|
|
currentValues[k] = v
|
|
}
|
|
}
|
|
|
|
// Determine effective subdomain (stored env > metadata fallback)
|
|
effectiveSubdomain := found.Meta.Subdomain
|
|
if sd, ok := currentValues["SUBDOMAIN"]; ok && sd != "" {
|
|
effectiveSubdomain = sd
|
|
}
|
|
|
|
data := s.baseData("stacks", found.Meta.DisplayName)
|
|
data["Stack"] = found
|
|
data["Meta"] = found.Meta
|
|
data["AppInfo"] = found.Meta.AppInfo
|
|
data["OptionalConfig"] = found.Meta.OptionalConfig
|
|
data["CurrentValues"] = currentValues
|
|
data["HasAppInfo"] = found.Meta.HasAppInfo()
|
|
data["HasOptionalConfig"] = found.Meta.HasOptionalConfig()
|
|
data["EffectiveSubdomain"] = effectiveSubdomain
|
|
|
|
s.executeTemplate(w, r, "app_info", data)
|
|
}
|
|
|
|
func (s *Server) monitoringHandler(w http.ResponseWriter, r *http.Request) {
|
|
data := s.baseData("monitoring", "Rendszermonitor")
|
|
data["SystemInfo"] = system.GetInfo(s.primaryHDDPath(), s.cpuCollector)
|
|
data["StorageBars"] = s.buildStorageBars()
|
|
|
|
if s.alertManager != nil {
|
|
data["Alerts"] = s.alertManager.GetAlerts()
|
|
data["DiskWarnings"] = s.alertManager.GetInlineAlerts("monitoring")
|
|
}
|
|
|
|
// Hub connection status section
|
|
data["HubEnabled"] = s.cfg.Hub.Enabled && s.cfg.Hub.URL != ""
|
|
data["HubURL"] = s.cfg.Hub.URL
|
|
data["CustomerID"] = s.cfg.Customer.ID
|
|
|
|
if s.hubPushStatusFn != nil {
|
|
ps := s.hubPushStatusFn()
|
|
data["HubLastAttempt"] = ps.LastAttempt
|
|
data["HubLastSuccess"] = ps.LastSuccess
|
|
data["HubLastError"] = ps.LastError
|
|
data["HubConsecutiveFailures"] = ps.Consecutive
|
|
// Connected if last success was within 2x the push interval (or 30min default)
|
|
connected := !ps.LastSuccess.IsZero() && time.Since(ps.LastSuccess) < 30*time.Minute
|
|
data["HubConnected"] = connected
|
|
}
|
|
|
|
// Legacy ping status section (still shown for backward compat during transition)
|
|
data["MonitoringEnabled"] = s.cfg.Monitoring.Enabled
|
|
if s.cfg.Monitoring.Enabled {
|
|
pings := []map[string]interface{}{
|
|
{"Label": "Eletjel (Heartbeat)", "Icon": "heartbeat", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.Heartbeat), "Schedule": "5 percenkent"},
|
|
{"Label": "Rendszer allapot", "Icon": "system", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.SystemHealth), "Schedule": "5 percenkent"},
|
|
{"Label": "Adatbazis mentes", "Icon": "db", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.DBDump), "Schedule": "Naponta " + s.cfg.Backup.DBDumpSchedule},
|
|
{"Label": "Biztonsagi mentes", "Icon": "backup", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.Backup), "Schedule": "Naponta " + s.cfg.Backup.ResticSchedule},
|
|
{"Label": "Mentes integritas", "Icon": "integrity", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.BackupIntegrity), "Schedule": "Hetente (vasarnap)"},
|
|
}
|
|
allConfigured := true
|
|
for _, p := range pings {
|
|
if !p["Configured"].(bool) {
|
|
allConfigured = false
|
|
break
|
|
}
|
|
}
|
|
data["PingStatus"] = pings
|
|
data["AllPingsConfigured"] = allConfigured
|
|
}
|
|
|
|
s.executeTemplate(w, r, "monitoring", data)
|
|
}
|
|
|
|
// isPingConfigured returns true if a healthcheck ping UUID is non-empty and not a placeholder.
|
|
func isPingConfigured(uuid string) bool {
|
|
return uuid != "" && !strings.HasPrefix(uuid, "CHANGEME")
|
|
}
|
|
|
|
func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
|
|
data := s.baseData("backups", "Biztonsági mentés")
|
|
|
|
// System info for storage overview bars
|
|
data["SystemInfo"] = system.GetInfo(s.primaryHDDPath(), s.cpuCollector)
|
|
data["StorageBars"] = s.buildStorageBars()
|
|
|
|
if s.backupMgr != nil {
|
|
nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule)
|
|
nextBackup := scheduler.NextDailyRun(s.cfg.Backup.ResticSchedule)
|
|
fullStatus := s.backupMgr.GetFullStatus(nextDBDump, nextBackup)
|
|
|
|
// Pass flash messages from query params (set by redirect handlers)
|
|
if flash := r.URL.Query().Get("flash"); flash != "" {
|
|
fullStatus.FlashSuccess = flash
|
|
}
|
|
if flashErr := r.URL.Query().Get("flash_error"); flashErr != "" {
|
|
fullStatus.FlashError = flashErr
|
|
}
|
|
|
|
// Enrich AppDataInfo with storage labels
|
|
storagePaths := s.settings.GetStoragePaths()
|
|
for i := range fullStatus.AppDataInfo {
|
|
app := &fullStatus.AppDataInfo[i]
|
|
if len(app.HDDPaths) > 0 {
|
|
hddPath := app.HDDPaths[0].HostPath
|
|
// Match HDD path prefix against registered storage paths
|
|
for _, sp := range storagePaths {
|
|
if strings.HasPrefix(hddPath, sp.Path) {
|
|
app.StorageLabel = sp.Label
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build cross-drive summary
|
|
crossConfigs := s.settings.GetAllCrossDriveConfigs()
|
|
|
|
// Build label lookup for dest paths
|
|
destLabels := make(map[string]string)
|
|
for _, sp := range storagePaths {
|
|
destLabels[sp.Path] = sp.Label
|
|
}
|
|
|
|
for _, app := range fullStatus.AppDataInfo {
|
|
cfg, hasCfg := crossConfigs[app.StackName]
|
|
if !hasCfg || cfg == nil {
|
|
fullStatus.UnconfiguredApps = append(fullStatus.UnconfiguredApps, backup.CrossDriveSummaryItem{
|
|
StackName: app.StackName,
|
|
DisplayName: app.DisplayName,
|
|
})
|
|
continue
|
|
}
|
|
|
|
item := backup.CrossDriveSummaryItem{
|
|
StackName: app.StackName,
|
|
DisplayName: app.DisplayName,
|
|
Method: cfg.Method,
|
|
DestPath: cfg.DestinationPath,
|
|
DestLabel: destLabels[cfg.DestinationPath],
|
|
Schedule: cfg.Schedule,
|
|
LastStatus: cfg.LastStatus,
|
|
SizeHuman: cfg.LastSizeHuman,
|
|
}
|
|
switch cfg.Method {
|
|
case "rsync":
|
|
item.MethodLabel = "rsync"
|
|
case "restic":
|
|
item.MethodLabel = "restic"
|
|
default:
|
|
item.MethodLabel = cfg.Method
|
|
}
|
|
switch cfg.Schedule {
|
|
case "daily":
|
|
item.ScheduleLabel = "Naponta"
|
|
case "weekly":
|
|
item.ScheduleLabel = "Hetente"
|
|
default:
|
|
item.ScheduleLabel = "Kézi"
|
|
}
|
|
if cfg.LastRun != "" {
|
|
if t, err := time.Parse(time.RFC3339, cfg.LastRun); err == nil {
|
|
item.LastRunShort = t.In(getTimezone()).Format("01-02 15:04")
|
|
}
|
|
}
|
|
fullStatus.CrossDriveSummary = append(fullStatus.CrossDriveSummary, item)
|
|
|
|
// Destination health warning (tiered validation)
|
|
if cfg.Enabled && cfg.DestinationPath != "" {
|
|
health := system.CheckBackupDestination(cfg.DestinationPath)
|
|
if health.Warning != "" {
|
|
prefix := "⚠️"
|
|
if health.Severity == "critical" {
|
|
prefix = "🔴"
|
|
}
|
|
fullStatus.CrossDriveWarnings = append(fullStatus.CrossDriveWarnings,
|
|
fmt.Sprintf("%s %s: %s", prefix, app.DisplayName, health.Warning))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build unified per-app backup rows for the new UI
|
|
data["AppBackupRows"] = s.buildAppBackupRows(fullStatus, crossConfigs, destLabels)
|
|
|
|
// Top-level warning: no user data backed up at all
|
|
hasAnyCrossDrive := false
|
|
hasAnyHDDApp := false
|
|
for _, app := range fullStatus.AppDataInfo {
|
|
if app.HasHDDData {
|
|
hasAnyHDDApp = true
|
|
if cfg, ok := crossConfigs[app.StackName]; ok && cfg != nil && cfg.Enabled {
|
|
hasAnyCrossDrive = true
|
|
}
|
|
}
|
|
}
|
|
if hasAnyHDDApp && !hasAnyCrossDrive {
|
|
data["NoUserDataBackupWarning"] = true
|
|
}
|
|
|
|
data["Backup"] = fullStatus
|
|
|
|
// Restic password for display
|
|
if pw, err := s.backupMgr.GetResticPassword(); err == nil {
|
|
data["ResticPassword"] = pw
|
|
}
|
|
|
|
// Részletek section: DB dump total size
|
|
var dbDumpTotalBytes int64
|
|
for _, f := range fullStatus.DumpFiles {
|
|
dbDumpTotalBytes += f.Size
|
|
}
|
|
data["DBDumpTotalBytes"] = dbDumpTotalBytes
|
|
|
|
// Részletek section: enrich per-drive repo stats with storage labels
|
|
for i := range fullStatus.PerDriveRepoStats {
|
|
for _, sp := range storagePaths {
|
|
if strings.HasPrefix(fullStatus.PerDriveRepoStats[i].DrivePath, sp.Path) ||
|
|
fullStatus.PerDriveRepoStats[i].DrivePath == sp.Path {
|
|
fullStatus.PerDriveRepoStats[i].DriveLabel = sp.Label
|
|
break
|
|
}
|
|
}
|
|
if fullStatus.PerDriveRepoStats[i].DriveLabel == "" {
|
|
fullStatus.PerDriveRepoStats[i].DriveLabel = filepath.Base(fullStatus.PerDriveRepoStats[i].DrivePath)
|
|
}
|
|
}
|
|
data["PerDriveRepoStats"] = fullStatus.PerDriveRepoStats
|
|
|
|
// Részletek section: group Tier 2 items by destination drive
|
|
tier2GroupMap := make(map[string]*Tier2DriveGroup)
|
|
for _, item := range fullStatus.CrossDriveSummary {
|
|
if item.DestPath == "" {
|
|
continue
|
|
}
|
|
grp, exists := tier2GroupMap[item.DestPath]
|
|
if !exists {
|
|
grp = &Tier2DriveGroup{
|
|
DestPath: item.DestPath,
|
|
DestLabel: item.DestLabel,
|
|
}
|
|
if grp.DestLabel == "" {
|
|
grp.DestLabel = filepath.Base(item.DestPath)
|
|
}
|
|
tier2GroupMap[item.DestPath] = grp
|
|
}
|
|
grp.Items = append(grp.Items, item)
|
|
}
|
|
var tier2Groups []Tier2DriveGroup
|
|
for _, grp := range tier2GroupMap {
|
|
tier2Groups = append(tier2Groups, *grp)
|
|
}
|
|
data["Tier2DriveGroups"] = tier2Groups
|
|
} else {
|
|
data["Backup"] = nil
|
|
}
|
|
|
|
s.executeTemplate(w, r, "backups", data)
|
|
}
|
|
|
|
// Tier2DriveGroup holds grouped Tier 2 cross-drive backup items for one destination drive.
|
|
type Tier2DriveGroup struct {
|
|
DestPath string
|
|
DestLabel string
|
|
Items []backup.CrossDriveSummaryItem
|
|
}
|
|
|
|
// AppBackupRow holds per-tier backup information for one app on the backup page.
|
|
type AppBackupRow struct {
|
|
StackName string
|
|
DisplayName string
|
|
Status string // "green", "yellow", "red", "auto"
|
|
StatusText string // short Hungarian tooltip
|
|
|
|
// App characteristics
|
|
HasHDDData bool
|
|
HasDB bool
|
|
StorageLabel string
|
|
HDDSizeHuman string
|
|
|
|
// What this app's backup contains (for display)
|
|
// e.g., "DB + Konfiguráció + Adatok", "DB + Konfiguráció", "Konfiguráció"
|
|
BackupContents string
|
|
|
|
// Tier 1: Nightly backup (always exists)
|
|
Tier1LastRun string // formatted time of last restic snapshot
|
|
Tier1LastStatus string // "ok", "error", ""
|
|
Tier1DBStatus string // "ok", "error", "" — separate DB dump status for warning
|
|
|
|
// Tier 2: Cross-drive backup (configurable for all apps)
|
|
Tier2Configured bool
|
|
Tier2Dest string // destination label
|
|
Tier2Schedule string // "Naponta", "Hetente"
|
|
Tier2LastRun string
|
|
Tier2LastStatus string // "ok", "error", "running", ""
|
|
Tier2LastError string
|
|
Tier2StatusBadge string // "Sikeres", "Hiba", "Fut...", "—"
|
|
Tier2SizeHuman string
|
|
|
|
// Drive disconnected — app's home drive is currently disconnected
|
|
DriveDisconnected bool
|
|
|
|
// Warnings accumulated for this app
|
|
Warnings []string
|
|
}
|
|
|
|
// buildAppBackupRows constructs one AppBackupRow per deployed app for the backup page.
|
|
func (s *Server) buildAppBackupRows(
|
|
status *backup.FullBackupStatus,
|
|
crossConfigs map[string]*settings.CrossDriveBackup,
|
|
destLabels map[string]string,
|
|
) []AppBackupRow {
|
|
loc := getTimezone()
|
|
|
|
// Build DB stack lookup
|
|
dbStacks := make(map[string]bool)
|
|
for _, db := range status.DiscoveredDBs {
|
|
dbStacks[db.StackName] = true
|
|
}
|
|
for _, f := range status.DumpFiles {
|
|
dbStacks[f.StackName] = true
|
|
}
|
|
|
|
// Tier 1 timestamps (shared across all apps — single nightly job)
|
|
tier1LastRun := ""
|
|
tier1LastStatus := ""
|
|
if status.LastBackup != nil {
|
|
tier1LastRun = status.LastBackup.LastRun.In(loc).Format("01-02 15:04")
|
|
if status.LastBackup.Success {
|
|
tier1LastStatus = "ok"
|
|
} else {
|
|
tier1LastStatus = "error"
|
|
}
|
|
}
|
|
tier1DBStatus := ""
|
|
if status.LastDBDump != nil {
|
|
if status.LastDBDump.Success {
|
|
tier1DBStatus = "ok"
|
|
} else {
|
|
tier1DBStatus = "error"
|
|
}
|
|
}
|
|
|
|
// Build disconnected paths set for drive-disconnected detection
|
|
disconnectedPaths := make(map[string]bool)
|
|
for _, dp := range s.settings.GetDisconnectedPaths() {
|
|
disconnectedPaths[dp.Path] = true
|
|
}
|
|
|
|
var rows []AppBackupRow
|
|
for _, app := range status.AppDataInfo {
|
|
hasDB := dbStacks[app.StackName] || app.HasDBDump
|
|
|
|
// Check if this app's home drive is disconnected
|
|
driveDisconnected := false
|
|
if app.HasHDDData && len(app.HDDPaths) > 0 {
|
|
for dp := range disconnectedPaths {
|
|
for _, hp := range app.HDDPaths {
|
|
if strings.HasPrefix(hp.HostPath, dp+"/") || hp.HostPath == dp {
|
|
driveDisconnected = true
|
|
break
|
|
}
|
|
}
|
|
if driveDisconnected {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build backup contents label
|
|
var parts []string
|
|
if hasDB {
|
|
parts = append(parts, "DB")
|
|
}
|
|
parts = append(parts, "Konfig")
|
|
if app.HasHDDData {
|
|
parts = append(parts, "Adatok")
|
|
}
|
|
contents := strings.Join(parts, " + ")
|
|
|
|
row := AppBackupRow{
|
|
StackName: app.StackName,
|
|
DisplayName: app.DisplayName,
|
|
HasHDDData: app.HasHDDData,
|
|
HasDB: hasDB,
|
|
DriveDisconnected: driveDisconnected,
|
|
StorageLabel: app.StorageLabel,
|
|
HDDSizeHuman: app.HDDSizeHuman,
|
|
BackupContents: contents,
|
|
|
|
Tier1LastRun: tier1LastRun,
|
|
Tier1LastStatus: tier1LastStatus,
|
|
Tier1DBStatus: tier1DBStatus,
|
|
}
|
|
|
|
// Status dot — start as yellow (1 tier only)
|
|
row.Status = "yellow"
|
|
row.StatusText = "Csak helyi mentés (1 szint)"
|
|
|
|
cfg, hasCfg := crossConfigs[app.StackName]
|
|
|
|
if !hasCfg || cfg == nil || !cfg.Enabled {
|
|
// Only Tier 1 — no second copy
|
|
row.Tier2Configured = false
|
|
} else {
|
|
row.Tier2Configured = true
|
|
row.Tier2Dest = destLabels[cfg.DestinationPath]
|
|
if row.Tier2Dest == "" {
|
|
row.Tier2Dest = cfg.DestinationPath
|
|
}
|
|
switch cfg.Schedule {
|
|
case "daily":
|
|
row.Tier2Schedule = "Naponta"
|
|
case "weekly":
|
|
row.Tier2Schedule = "Hetente"
|
|
default:
|
|
row.Tier2Schedule = cfg.Schedule
|
|
}
|
|
if cfg.LastRun != "" {
|
|
if t, err := time.Parse(time.RFC3339, cfg.LastRun); err == nil {
|
|
row.Tier2LastRun = t.In(loc).Format("01-02 15:04")
|
|
}
|
|
}
|
|
row.Tier2LastStatus = cfg.LastStatus
|
|
row.Tier2LastError = cfg.LastError
|
|
row.Tier2SizeHuman = cfg.LastSizeHuman
|
|
switch cfg.LastStatus {
|
|
case "ok":
|
|
row.Tier2StatusBadge = "Sikeres"
|
|
row.Status = "green"
|
|
row.StatusText = "Mentés rendben"
|
|
case "error":
|
|
row.Tier2StatusBadge = "Hiba"
|
|
// Status stays yellow
|
|
row.StatusText = "Utolsó mentés sikertelen"
|
|
case "running":
|
|
row.Tier2StatusBadge = "Fut..."
|
|
default:
|
|
row.Tier2StatusBadge = "—"
|
|
// Tier2 configured but never run — stay yellow
|
|
}
|
|
|
|
// Destination health check — can downgrade green to yellow/red
|
|
if cfg.DestinationPath != "" && s.crossDriveRunner != nil {
|
|
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] 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] Failed to save cross-drive config for %s: %v", name, err)
|
|
http.Redirect(w, r, "/stacks/"+name+"/deploy?flash_error=Hiba+a+ment%C3%A9si+be%C3%A1ll%C3%ADt%C3%A1s+ment%C3%A9sakor", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
s.logger.Printf("[INFO] Cross-drive backup config saved for %s: dest=%s schedule=%s enabled=%v",
|
|
name, destPath, schedule, enabled)
|
|
|
|
http.Redirect(w, r, "/stacks/"+name+"/deploy?flash=Ment%C3%A9si+be%C3%A1ll%C3%ADt%C3%A1s+mentve.", http.StatusFound)
|
|
}
|
|
|
|
func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
|
_ = r.ParseForm()
|
|
|
|
stackName := r.FormValue("stack_name")
|
|
snapshotID := r.FormValue("snapshot_id")
|
|
|
|
if 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] Restore requested: stack=%s, snapshot=%s from %s", stackName, snapshotID, r.RemoteAddr)
|
|
|
|
start := time.Now()
|
|
if err := s.backupMgr.RestoreApp(stackName, snapshotID); err != nil {
|
|
s.logger.Printf("[ERROR] 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] Failed to hash new password: %v", err)
|
|
data["PasswordError"] = "Belső hiba a jelszó mentésekor"
|
|
s.executeTemplate(w, r, "settings", data)
|
|
return
|
|
}
|
|
|
|
// Save to settings.json
|
|
if err := s.settings.SetPasswordHash(string(hash)); err != nil {
|
|
s.logger.Printf("[ERROR] Failed to save password to settings.json: %v", err)
|
|
data["PasswordError"] = "Belső hiba a jelszó mentésekor"
|
|
s.executeTemplate(w, r, "settings", data)
|
|
return
|
|
}
|
|
|
|
s.logger.Printf("[INFO] Password changed via settings page from %s", r.RemoteAddr)
|
|
|
|
// Invalidate all sessions (force re-login)
|
|
s.invalidateAllSessions()
|
|
|
|
// Redirect to login with flash message
|
|
flash := url.QueryEscape("Jelszó sikeresen módosítva. Kérjük, jelentkezzen be az új jelszóval.")
|
|
http.Redirect(w, r, "/login?flash="+flash, http.StatusFound)
|
|
}
|
|
|
|
func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Request) {
|
|
_ = r.ParseForm()
|
|
|
|
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] Failed to save notification prefs: %v", err)
|
|
data := s.settingsData()
|
|
data["NotificationError"] = "Hiba a beállítások mentésekor"
|
|
s.executeTemplate(w, r, "settings", data)
|
|
return
|
|
}
|
|
|
|
s.logger.Printf("[INFO] Notification preferences updated: email=%s, events=%v", email, enabledEvents)
|
|
|
|
// Sync preferences to hub
|
|
data := s.settingsData()
|
|
if s.notifier != nil && s.notifier.IsEnabled() {
|
|
if err := s.notifier.SyncPreferences(email, enabledEvents, cooldownHours); err != nil {
|
|
s.logger.Printf("[WARN] Failed to sync preferences to hub: %v", err)
|
|
data["NotificationSuccess"] = fmt.Sprintf("Értesítési beállítások mentve (helyi). A központi szinkronizálás sikertelen: %v", err)
|
|
} else {
|
|
data["NotificationSuccess"] = "Értesítési beállítások mentve."
|
|
}
|
|
} else {
|
|
data["NotificationSuccess"] = "Értesítési beállítások mentve."
|
|
}
|
|
s.executeTemplate(w, r, "settings", data)
|
|
}
|
|
|
|
func (s *Server) settingsNotificationsTestHandler(w http.ResponseWriter, r *http.Request) {
|
|
data := s.settingsData()
|
|
|
|
if s.notifier == nil {
|
|
data["NotificationError"] = "Az értesítések nincsenek bekapcsolva"
|
|
s.executeTemplate(w, r, "settings", data)
|
|
return
|
|
}
|
|
|
|
err := s.notifier.SendTest()
|
|
if err != nil {
|
|
s.logger.Printf("[ERROR] Test notification failed: %v", err)
|
|
data["NotificationError"] = fmt.Sprintf("Teszt email küldése sikertelen: %v", err)
|
|
s.executeTemplate(w, r, "settings", data)
|
|
return
|
|
}
|
|
|
|
data["NotificationSuccess"] = "Teszt email elküldve."
|
|
s.executeTemplate(w, r, "settings", data)
|
|
}
|
|
|
|
// --- Storage path management handlers ---
|
|
|
|
func (s *Server) countAppsUsingPath(storagePath string) int {
|
|
count := 0
|
|
for _, stack := range s.stackMgr.GetStacks() {
|
|
if !stack.Deployed {
|
|
continue
|
|
}
|
|
if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil {
|
|
if appCfg.Env["HDD_PATH"] == storagePath {
|
|
count++
|
|
}
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
func (s *Server) appsUsingPath(storagePath string) []string {
|
|
var names []string
|
|
for _, stack := range s.stackMgr.GetStacks() {
|
|
if !stack.Deployed {
|
|
continue
|
|
}
|
|
if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil {
|
|
if appCfg.Env["HDD_PATH"] == storagePath {
|
|
names = append(names, stack.Meta.DisplayName)
|
|
}
|
|
}
|
|
}
|
|
return names
|
|
}
|
|
|
|
func (s *Server) appDetailsForPath(storagePath string) []StorageAppDetail {
|
|
var details []StorageAppDetail
|
|
for _, stack := range s.stackMgr.GetStacks() {
|
|
if !stack.Deployed {
|
|
continue
|
|
}
|
|
appCfg := s.stackMgr.LoadAppConfigByName(stack.Name)
|
|
if appCfg == nil {
|
|
continue
|
|
}
|
|
hddPath := appCfg.Env["HDD_PATH"]
|
|
if hddPath != storagePath {
|
|
continue
|
|
}
|
|
detail := StorageAppDetail{
|
|
Name: stack.Meta.DisplayName,
|
|
Stack: stack.Meta.Slug,
|
|
}
|
|
// Try to get data size from the storage subdirectory
|
|
appDataDir := backup.AppDataDir(storagePath, stack.Name)
|
|
if fi, err := os.Stat(appDataDir); err == nil && fi.IsDir() {
|
|
detail.SizeHuman = dirSizeHuman(appDataDir)
|
|
}
|
|
details = append(details, detail)
|
|
}
|
|
return details
|
|
}
|
|
|
|
// dirSizeHuman returns a human-readable size for a directory.
|
|
func dirSizeHuman(path string) string {
|
|
var total int64
|
|
filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {
|
|
if err != nil || info.IsDir() {
|
|
return nil
|
|
}
|
|
total += info.Size()
|
|
return nil
|
|
})
|
|
const (
|
|
KB = 1024
|
|
MB = KB * 1024
|
|
GB = MB * 1024
|
|
)
|
|
switch {
|
|
case total >= GB:
|
|
return fmt.Sprintf("%.1f GB", float64(total)/float64(GB))
|
|
case total >= MB:
|
|
return fmt.Sprintf("%.1f MB", float64(total)/float64(MB))
|
|
case total >= KB:
|
|
return fmt.Sprintf("%.1f KB", float64(total)/float64(KB))
|
|
default:
|
|
return fmt.Sprintf("%d B", total)
|
|
}
|
|
}
|
|
|
|
func formatFreeSpace(gb float64) string {
|
|
if gb >= 1000 {
|
|
return fmt.Sprintf("%.1f TB", gb/1024)
|
|
}
|
|
return fmt.Sprintf("%.1f GB", gb)
|
|
}
|
|
|
|
func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Request) {
|
|
_ = r.ParseForm()
|
|
|
|
path := filepath.Clean(r.FormValue("storage_path"))
|
|
label := strings.TrimSpace(r.FormValue("storage_label"))
|
|
isDefault := r.FormValue("storage_default") == "true"
|
|
|
|
if 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] Storage path %s is not under /mnt/ — unusual but allowed", path)
|
|
}
|
|
|
|
sp := settings.StoragePath{
|
|
Path: path,
|
|
Label: label,
|
|
IsDefault: isDefault,
|
|
Schedulable: true,
|
|
AddedAt: time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
|
|
if err := s.settings.AddStoragePath(sp); err != nil {
|
|
s.logger.Printf("[ERROR] Failed to add storage path: %v", err)
|
|
data["StorageError"] = "Hiba a mentés során."
|
|
s.executeTemplate(w, r, "settings", data)
|
|
return
|
|
}
|
|
|
|
s.logger.Printf("[INFO] Storage path added: %s (%s)", path, label)
|
|
go s.SyncFileBrowserMounts()
|
|
http.Redirect(w, r, "/settings?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló sikeresen hozzáadva: "+path), http.StatusFound)
|
|
}
|
|
|
|
func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Request) {
|
|
_ = r.ParseForm()
|
|
path := r.FormValue("storage_path")
|
|
|
|
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] 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] 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 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] 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 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] Failed to set storage label: %v", err)
|
|
data := s.settingsData()
|
|
data["StorageError"] = "Hiba a megnevezés mentésekor."
|
|
s.executeTemplate(w, r, "settings", data)
|
|
return
|
|
}
|
|
|
|
s.logger.Printf("[INFO] Storage label updated: %s → %q", path, label)
|
|
http.Redirect(w, r, "/settings?storage_msg=success&storage_detail="+url.QueryEscape("Megnevezés módosítva: "+label), http.StatusFound)
|
|
}
|
|
|
|
// SyncFileBrowserMounts regenerates FileBrowser's docker-compose.yml and config.yaml
|
|
// with volume mounts and sources for all registered storage paths, then recreates the container.
|
|
func (s *Server) SyncFileBrowserMounts() {
|
|
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] FileBrowser stack not found at %s — skipping mount sync", composePath)
|
|
return
|
|
}
|
|
|
|
// Get all active storage paths
|
|
paths := s.settings.GetStoragePaths()
|
|
|
|
// Use domain from controller config
|
|
domain := s.cfg.Customer.Domain
|
|
if domain == "" {
|
|
s.logger.Printf("[WARN] Cannot sync FileBrowser mounts — customer domain not configured")
|
|
return
|
|
}
|
|
|
|
// Build volume mount lines
|
|
var storageMounts []string
|
|
for _, sp := range paths {
|
|
mountName := filepath.Base(sp.Path) // "/mnt/hdd_1" → "hdd_1"
|
|
line := fmt.Sprintf(" - %s:/srv/%s", sp.Path, mountName)
|
|
storageMounts = append(storageMounts, line)
|
|
}
|
|
|
|
// Generate and write config.yaml (sources + sidebar entries per drive)
|
|
configPath := stackDir + "/config.yaml"
|
|
fbConfig := generateFileBrowserConfig(paths)
|
|
|
|
// 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] 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] 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] 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] 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] 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)
|
|
}
|