0e20eb19c1
In-process go-smtp shim (Shape 1): apps → shim → hub → Resend, Resend key stays hub-side. From-header allowlist (reject 5xx pre-hub), single-shot raw-MIME forward, status→SMTP mapping. Global + per-app toggles gate compose-time env injection from .felhom.yml smtp_mapping. Hungarian UI on settings + app config pages. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1663 lines
58 KiB
Go
1663 lines
58 KiB
Go
package web
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/crypto"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
|
|
"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"
|
|
Purpose string // Hungarian explanation of what this drive holds (monitoring page)
|
|
TotalGB float64
|
|
UsedGB float64
|
|
Percent float64
|
|
Disconnected bool
|
|
}
|
|
|
|
// storageBarPurpose is the Hungarian description for the registered user-data drives shown in the
|
|
// monitoring "Tárolók kapacitása" list. These are all external/user-data drives (the agent's
|
|
// system/PBS storage is not in the controller's storage-path registry), matching the user-data
|
|
// purpose text on the storage-management page (Phase 4C).
|
|
const storageBarPurpose = "Külső adattároló — a telepített alkalmazások nagy méretű fájljai (média, dokumentumok) ide kerülnek; az adatbázisok a belső SSD-n vannak."
|
|
|
|
// buildStorageBars returns usage bars for all registered storage paths, in a stable order
|
|
// (by path) with a purpose description.
|
|
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,
|
|
Purpose: storageBarPurpose,
|
|
Disconnected: true,
|
|
})
|
|
continue
|
|
}
|
|
di := system.GetDiskUsage(sp.Path)
|
|
if di == nil {
|
|
continue
|
|
}
|
|
bars = append(bars, StorageBarInfo{
|
|
Label: sp.Label,
|
|
Path: sp.Path,
|
|
Purpose: storageBarPurpose,
|
|
TotalGB: di.TotalGB,
|
|
UsedGB: di.UsedGB,
|
|
Percent: di.UsedPercent,
|
|
})
|
|
}
|
|
// Deterministic order regardless of registry insertion order.
|
|
sort.Slice(bars, func(i, j int) bool { return bars[i].Path < bars[j].Path })
|
|
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["SettingsWarning"] = s.settings.LoadWarning // non-empty if settings.json was recovered from corruption
|
|
data["Stacks"] = deployedStacks
|
|
data["MissingStorage"] = s.missingStorageMap(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)
|
|
fullStatus := s.backupMgr.GetFullStatus(nextDBDump)
|
|
data["DBDumpStatus"] = fullStatus.LastDBDump
|
|
data["BackupRunning"] = fullStatus.Running
|
|
data["BackupMaxAgeHours"] = s.cfg.Monitoring.Thresholds.BackupMaxAgeHours
|
|
}
|
|
|
|
// 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")
|
|
allStacks := s.stackMgr.GetStacks()
|
|
data["Stacks"] = allStacks
|
|
data["MissingStorage"] = s.missingStorageMap(allStacks)
|
|
|
|
// 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
|
|
|
|
// Prevention layer (storage-split): surface the Docker-data volume's reserved-buffer state so the
|
|
// customer sees BEFORE deploying when free space is too low (the API gate also hard-refuses). Only
|
|
// meaningful for a NEW deploy (an existing app's config save doesn't consume fresh image space).
|
|
if !alreadyDeployed {
|
|
if hr := system.GetDockerVolumeHeadroom(); hr.OK {
|
|
data["DockerBelowReserve"] = hr.BelowReserve
|
|
data["DockerFreeHuman"] = formatFreeSpace(hr.AvailGB)
|
|
data["DockerReserveHuman"] = formatFreeSpace(hr.ReserveGB)
|
|
}
|
|
}
|
|
|
|
// Effective subdomain for "Megnyitás" button
|
|
if alreadyDeployed && appCfg != nil {
|
|
if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd != "" {
|
|
data["EffectiveSubdomain"] = sd
|
|
}
|
|
}
|
|
|
|
// Disk-tier storage management (drive info, stale-data cleanup, cross-drive
|
|
// backup) has moved to the host agent (slice 8C); the deploy page no longer
|
|
// renders those sections.
|
|
if alreadyDeployed {
|
|
// App-to-app integrations
|
|
if im := s.integrationMgr.Load(); meta.HasIntegrations() && im != nil {
|
|
data["HasIntegrations"] = true
|
|
data["Integrations"] = im.ListForProvider(meta.Slug)
|
|
}
|
|
|
|
// Geo-restriction per-app data
|
|
geo := s.settings.GetGeoRestriction()
|
|
if geo != nil && geo.Enabled && s.cfg.Infrastructure.CFAPIToken != "" {
|
|
data["GeoGlobalEnabled"] = true
|
|
data["GeoGlobalCountries"] = geo.AllowedCountries
|
|
if ov, ok := geo.AppOverrides[name]; ok {
|
|
data["GeoAppOverride"] = true
|
|
data["GeoAppOverrideCountries"] = ov.AllowedCountries
|
|
} else {
|
|
data["GeoAppOverrideCountries"] = []string{}
|
|
}
|
|
} else {
|
|
data["GeoGlobalCountries"] = []string{}
|
|
data["GeoAppOverrideCountries"] = []string{}
|
|
}
|
|
|
|
// Optional config (metadata providers, etc.)
|
|
if meta.HasOptionalConfig() {
|
|
data["HasOptionalConfig"] = true
|
|
data["OptionalConfig"] = meta.OptionalConfig
|
|
optValues := make(map[string]string)
|
|
if decryptedEnv != nil {
|
|
for _, group := range meta.OptionalConfig {
|
|
for _, field := range group.Fields {
|
|
if val, ok := decryptedEnv[field.EnvVar]; ok {
|
|
optValues[field.EnvVar] = val
|
|
}
|
|
}
|
|
}
|
|
}
|
|
data["CurrentValues"] = optValues
|
|
}
|
|
|
|
// App-email per-app toggle — only for apps that declare an smtp_mapping. Shown with
|
|
// honest context whether or not the global toggle is on.
|
|
if supported, enabled := s.stackMgr.AppEmailStatus(name); supported {
|
|
data["AppEmailSupported"] = true
|
|
data["AppEmailAppOn"] = enabled
|
|
data["AppEmailGlobalOn"] = s.settings.AppEmailEnabled()
|
|
local := meta.SMTPMapping.FromLocal
|
|
if local == "" {
|
|
local = meta.Slug
|
|
}
|
|
domain := "felhom.eu"
|
|
if len(s.cfg.MailRelay.FromDomains) > 0 && s.cfg.MailRelay.FromDomains[0] != "" {
|
|
domain = s.cfg.MailRelay.FromDomains[0]
|
|
}
|
|
data["AppEmailFromAddress"] = local + "@" + domain
|
|
}
|
|
}
|
|
|
|
// Memory info for deploy page (only for non-deployed apps)
|
|
if !alreadyDeployed {
|
|
memInfo := map[string]interface{}{"Available": false}
|
|
totalMB, usedMB, memErr := system.GetMemoryMB()
|
|
if memErr == nil {
|
|
reservedMB := s.cfg.System.ReservedMemoryMB
|
|
usableMB := totalMB - reservedMB
|
|
newReqMB := stacks.ParseMemoryMB(meta.Resources.MemRequest)
|
|
afterMB := usedMB + newReqMB
|
|
percent := 0
|
|
if usableMB > 0 {
|
|
percent = afterMB * 100 / usableMB
|
|
}
|
|
usedPercent := 0
|
|
if usableMB > 0 {
|
|
usedPercent = usedMB * 100 / usableMB
|
|
}
|
|
|
|
// Overcommit warning still uses declared limits
|
|
_, committedLimitMB := s.stackMgr.CommittedMemory()
|
|
newLimitMB := stacks.ParseMemoryMB(meta.Resources.MemLimit)
|
|
afterLimitMB := committedLimitMB + newLimitMB
|
|
|
|
memInfo["Available"] = true
|
|
memInfo["TotalMB"] = totalMB
|
|
memInfo["ReservedMB"] = reservedMB
|
|
memInfo["UsableMB"] = usableMB
|
|
memInfo["UsedMB"] = usedMB
|
|
memInfo["NewRequestMB"] = newReqMB
|
|
memInfo["AfterMB"] = afterMB
|
|
memInfo["Percent"] = percent
|
|
memInfo["UsedPercent"] = usedPercent
|
|
memInfo["Blocked"] = newReqMB > 0 && afterMB > usableMB
|
|
memInfo["OvercommitWarn"] = newLimitMB > 0 && afterLimitMB > totalMB
|
|
}
|
|
data["MemoryInfo"] = memInfo
|
|
}
|
|
|
|
// Flash messages from cross-drive backup save redirect
|
|
if flash := r.URL.Query().Get("flash"); flash != "" {
|
|
data["FlashSuccess"] = flash
|
|
}
|
|
if flashErr := r.URL.Query().Get("flash_error"); flashErr != "" {
|
|
data["FlashError"] = flashErr
|
|
}
|
|
|
|
s.executeTemplate(w, r, "deploy", data)
|
|
}
|
|
|
|
func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug string) {
|
|
var found *stacks.Stack
|
|
for _, stack := range s.stackMgr.GetStacks() {
|
|
if stack.Meta.Slug == slug {
|
|
found = &stack
|
|
break
|
|
}
|
|
}
|
|
if found == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
// Determine effective subdomain (stored env > metadata fallback)
|
|
effectiveSubdomain := found.Meta.Subdomain
|
|
if appCfg := s.stackMgr.LoadAppConfigByName(found.Name); appCfg != nil {
|
|
if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd != "" {
|
|
effectiveSubdomain = sd
|
|
}
|
|
}
|
|
|
|
data := s.baseData("stacks", found.Meta.DisplayName)
|
|
data["Stack"] = found
|
|
data["Meta"] = found.Meta
|
|
data["AppInfo"] = found.Meta.AppInfo
|
|
data["HasAppInfo"] = found.Meta.HasAppInfo()
|
|
data["EffectiveSubdomain"] = effectiveSubdomain
|
|
|
|
// Initial auto-generated login (e.g. Crafty writes a random admin password to a file at first
|
|
// boot). Read it live from the container so the customer doesn't have to dig through logs. Only
|
|
// for deployed apps that declare an initial_credentials spec; hidden when unreadable.
|
|
if found.Deployed && found.Meta.InitialCreds != nil {
|
|
if creds, err := s.stackMgr.ReadInitialCredentials(found.Name); err != nil {
|
|
s.logger.Printf("[WARN] [web] initial-creds for %s: %v", found.Name, err)
|
|
} else if creds != nil && creds.Available {
|
|
data["InitialCreds"] = creds
|
|
}
|
|
}
|
|
|
|
// Per-app migration (B1): offer to move this app's data to another connected drive (≠ current).
|
|
if found.Deployed {
|
|
current := ""
|
|
if appCfg := s.stackMgr.LoadAppConfigByName(found.Name); appCfg != nil {
|
|
current = appCfg.Env["HDD_PATH"]
|
|
}
|
|
var targets []settings.StoragePath
|
|
for _, sp := range s.settings.GetStoragePaths() {
|
|
if sp.Path == current || sp.Decommissioned || sp.Disconnected || !sp.Schedulable {
|
|
continue
|
|
}
|
|
targets = append(targets, sp)
|
|
}
|
|
data["MigrateTargets"] = targets
|
|
data["MigrateCurrent"] = current
|
|
if label, missing := s.missingStorageLabel(current); missing {
|
|
data["MissingStorageLabel"] = label
|
|
}
|
|
}
|
|
|
|
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()
|
|
|
|
// Whole-guest backup view (agent-sourced, read-only) for the "Rendszermentés" section.
|
|
data["GuestBackup"] = s.loadGuestBackup(r.Context())
|
|
|
|
if s.backupMgr != nil {
|
|
nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule)
|
|
fullStatus := s.backupMgr.GetFullStatus(nextDBDump)
|
|
|
|
// 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 unified per-app backup rows for the app-data backup UI.
|
|
// Disk-tier (cross-drive / restic) backup has moved to the host agent.
|
|
data["AppBackupRows"] = s.buildAppBackupRows(fullStatus)
|
|
|
|
data["Backup"] = fullStatus
|
|
|
|
// DB dump total size
|
|
var dbDumpTotalBytes int64
|
|
for _, f := range fullStatus.DumpFiles {
|
|
dbDumpTotalBytes += f.Size
|
|
}
|
|
data["DBDumpTotalBytes"] = dbDumpTotalBytes
|
|
} else {
|
|
data["Backup"] = nil
|
|
}
|
|
|
|
s.executeTemplate(w, r, "backups", data)
|
|
}
|
|
|
|
// 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
|
|
HasVolumeData bool
|
|
StorageLabel string
|
|
HDDSizeHuman string
|
|
|
|
// What this app's backup contains (for display)
|
|
// e.g., "DB + Konfiguráció + Adatok", "DB + Konfiguráció", "Konfiguráció"
|
|
BackupContents string
|
|
|
|
// Tier 1: Nightly backup (always exists)
|
|
Tier1LastRun string // formatted time of last restic snapshot
|
|
Tier1LastStatus string // "ok", "error", ""
|
|
Tier1DBStatus string // "ok", "error", "" — separate DB dump status for warning
|
|
|
|
// Tier 2: Cross-drive backup (configurable for all apps)
|
|
Tier2Configured bool
|
|
Tier2Dest string // destination label
|
|
Tier2Schedule string // "Naponta", "Hetente"
|
|
Tier2LastRun string
|
|
Tier2LastStatus string // "ok", "error", "running", ""
|
|
Tier2LastError string
|
|
Tier2StatusBadge string // "Sikeres", "Hiba", "Fut...", "—"
|
|
Tier2SizeHuman string
|
|
|
|
// Drive disconnected — app's home drive is currently disconnected
|
|
DriveDisconnected bool
|
|
// Tier2 destination drive is currently disconnected (backup paused, not failed)
|
|
Tier2DestDisconnected bool
|
|
// Tier2 destination drive is inactive (Schedulable=false, backup paused)
|
|
Tier2DestInactive bool
|
|
// Tier2UserDisabled — customer turned Tier 2 off for this app from the config panel.
|
|
Tier2UserDisabled bool
|
|
|
|
// Warnings accumulated for this app
|
|
Warnings []string
|
|
}
|
|
|
|
// buildAppBackupRows constructs one AppBackupRow per deployed app for the backup page.
|
|
// Disk-tier (cross-drive / restic) backup has moved to the host agent; this now
|
|
// reflects only the app-data backup (DB dumps + Docker-volume tars).
|
|
func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackupRow {
|
|
// 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
|
|
}
|
|
|
|
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 || app.HasVolumeData {
|
|
parts = append(parts, "Adatok")
|
|
}
|
|
contents := strings.Join(parts, " + ")
|
|
|
|
row := AppBackupRow{
|
|
StackName: app.StackName,
|
|
DisplayName: app.DisplayName,
|
|
HasHDDData: app.HasHDDData,
|
|
HasDB: hasDB,
|
|
HasVolumeData: app.HasVolumeData,
|
|
DriveDisconnected: driveDisconnected,
|
|
StorageLabel: app.StorageLabel,
|
|
HDDSizeHuman: app.HDDSizeHuman,
|
|
BackupContents: contents,
|
|
Tier1DBStatus: tier1DBStatus,
|
|
}
|
|
|
|
// Status dot — app-data backup status
|
|
row.Status = "green"
|
|
row.StatusText = "Alkalmazás-adat mentés rendben"
|
|
if hasDB && tier1DBStatus == "error" {
|
|
row.Status = "yellow"
|
|
row.StatusText = "Adatbázis mentés sikertelen"
|
|
}
|
|
|
|
// Tier 2 (off-drive copy) status, from the config the Tier 2 runner persists.
|
|
if cd := s.settings.GetCrossDriveConfig(app.StackName); cd != nil {
|
|
row.Tier2UserDisabled = cd.UserDisabled
|
|
if cd.UserDisabled {
|
|
// Customer turned Tier 2 off — show nothing more; the panel button still appears.
|
|
} else if cd.LastStatus == "no_target" {
|
|
// Auto Tier 2 found no off-drive target — surface the honest reason (no silent gap).
|
|
row.Tier2Configured = false
|
|
row.Tier2StatusBadge = "Nincs 2. meghajtó"
|
|
row.Tier2LastError = cd.LastError
|
|
} else if cd.Enabled {
|
|
row.Tier2Configured = true
|
|
row.Tier2Dest = tier2DestLabel(cd.DestinationPath, s.cfg.Paths.SystemDataPath)
|
|
row.Tier2Schedule = "Naponta"
|
|
row.Tier2LastRun = cd.LastRun
|
|
row.Tier2LastStatus = cd.LastStatus
|
|
row.Tier2LastError = cd.LastError
|
|
row.Tier2SizeHuman = cd.LastSizeHuman
|
|
switch cd.LastStatus {
|
|
case "ok":
|
|
row.Tier2StatusBadge = "Sikeres"
|
|
case "error":
|
|
row.Tier2StatusBadge = "Hiba"
|
|
case "running":
|
|
row.Tier2StatusBadge = "Fut..."
|
|
default:
|
|
row.Tier2StatusBadge = "—"
|
|
}
|
|
}
|
|
}
|
|
|
|
rows = append(rows, row)
|
|
}
|
|
return rows
|
|
}
|
|
|
|
// tier2DestLabel renders a friendly destination label for the "2. mentés" card. A destination under
|
|
// the system-data path is the internal SSD (DB/config only); otherwise it's an external drive.
|
|
func tier2DestLabel(destPath, systemDataPath string) string {
|
|
if systemDataPath != "" && strings.HasPrefix(destPath, systemDataPath) {
|
|
return "belső SSD (csak DB/konfiguráció)"
|
|
}
|
|
return filepath.Base(strings.TrimSuffix(destPath, "/"+backup.FelhomDataDir))
|
|
}
|
|
|
|
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
|
|
}
|
|
// F2 (defense-in-depth): a stack name is a single segment, never a path. Reject traversal before any
|
|
// restore work — never let it reach RestoreFromRecoveryUnit.
|
|
if !validStackName(stackName) {
|
|
s.logger.Printf("[WARN] [web] restore rejected: invalid stack_name %q from %s", stackName, r.RemoteAddr)
|
|
http.Redirect(w, r, "/backups?flash_error=%C3%89rv%C3%A9nytelen+alkalmaz%C3%A1sn%C3%A9v", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
if s.backupMgr == nil {
|
|
http.Redirect(w, r, "/backups?flash_error=Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
s.logger.Printf("[WARN] [web] Restore requested: stack=%s, snapshot=%s from %s", stackName, snapshotID, r.RemoteAddr)
|
|
|
|
start := time.Now()
|
|
// Phase 2b: restore from the app's recovery unit (recovers secrets from the guest, fail-closed on
|
|
// an unrecoverable data-encrypting key; falls back to volume-only restore if no unit exists).
|
|
err := s.backupMgr.RestoreFromRecoveryUnit(stackName)
|
|
if err != nil {
|
|
s.logger.Printf("[ERROR] [web] Restore failed: %v", err)
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] [web] backupRestoreHandler: stack=%s failed after %s", stackName, time.Since(start))
|
|
}
|
|
errMsg := url.QueryEscape("Visszaállítás sikertelen: " + err.Error())
|
|
http.Redirect(w, r, "/backups?flash_error="+errMsg, http.StatusFound)
|
|
return
|
|
}
|
|
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] [web] backupRestoreHandler: stack=%s completed in %s", stackName, time.Since(start))
|
|
}
|
|
|
|
msg := url.QueryEscape(stackName + " visszaállítva (" + snapshotID + ").")
|
|
http.Redirect(w, r, "/backups?flash="+msg, http.StatusFound)
|
|
}
|
|
|
|
func (s *Server) settingsData() map[string]interface{} {
|
|
data := s.baseData("settings", "Beállítások")
|
|
data["CustomerID"] = s.cfg.Customer.ID
|
|
data["CustomerDomain"] = s.cfg.Customer.Domain
|
|
data["GitRepoURL"] = s.cfg.Git.RepoURL
|
|
data["GitSyncInterval"] = s.cfg.Git.SyncInterval
|
|
data["BackupEnabled"] = s.cfg.Backup.Enabled
|
|
data["DBDumpSchedule"] = s.cfg.Backup.DBDumpSchedule
|
|
data["ResticSchedule"] = s.cfg.Backup.ResticSchedule
|
|
data["MonitoringEnabled"] = s.cfg.Monitoring.Enabled
|
|
data["HealthchecksBase"] = s.cfg.Monitoring.HealthchecksBase
|
|
data["HubEnabled"] = s.cfg.Hub.Enabled
|
|
|
|
// Self-update status
|
|
data["SelfUpdateEnabled"] = s.cfg.SelfUpdate.Enabled
|
|
if s.updater != nil {
|
|
status := s.updater.GetStatus()
|
|
data["UpdateRunning"] = status.Running
|
|
if status.LastCheck != nil {
|
|
data["UpdateAvailable"] = status.LastCheck.UpdateAvailable
|
|
data["LatestVersion"] = status.LastCheck.LatestVersion
|
|
data["LastCheckTime"] = status.LastCheck.CheckedAt
|
|
data["LastCheckError"] = status.LastCheck.Error
|
|
}
|
|
if status.LastState != nil {
|
|
data["LastUpdateState"] = status.LastState
|
|
}
|
|
data["AutoUpdateEnabled"] = s.cfg.SelfUpdate.AutoUpdate
|
|
data["AutoUpdateTime"] = s.cfg.SelfUpdate.AutoUpdateTime
|
|
// Phase 2 managed updates: the operator-enforced minimum version (FLOOR) the box auto-updates
|
|
// to. Empty = none set by the operator.
|
|
data["ControllerFloor"] = s.updater.GetFloor()
|
|
}
|
|
|
|
data["NotificationPrefs"] = s.settings.GetNotificationPrefs()
|
|
|
|
// App-email (SMTP relay) — global toggle. Only meaningful when a hub is configured (the relay
|
|
// path runs through the hub); the template hides the control otherwise.
|
|
appEmail := s.settings.GetAppEmail()
|
|
data["AppEmailEnabled"] = appEmail.Enabled
|
|
data["AppEmailFromName"] = appEmail.FromName
|
|
data["AppEmailAvailable"] = s.cfg.Hub.URL != "" && s.cfg.MailRelay.HardEnabled()
|
|
|
|
// Storage paths with display data
|
|
storagePaths := s.settings.GetStoragePaths()
|
|
connectedCount := 0
|
|
for _, sp := range storagePaths {
|
|
if !sp.Disconnected && !sp.Decommissioned {
|
|
connectedCount++
|
|
}
|
|
}
|
|
var storageViews []StoragePathView
|
|
for _, sp := range storagePaths {
|
|
view := StoragePathView{
|
|
StoragePath: sp,
|
|
StoppedApps: sp.StoppedStacks,
|
|
HasOtherPaths: connectedCount > 1,
|
|
}
|
|
if sp.Disconnected {
|
|
// Skip I/O calls on disconnected drives — they'd hang or fail
|
|
view.IsMounted = false
|
|
} else if sp.Decommissioned {
|
|
view.IsMounted = false
|
|
view.MigratedToLabel = s.settings.GetStorageLabel(sp.MigratedTo)
|
|
} else {
|
|
view.IsMounted = system.IsMountPoint(sp.Path)
|
|
view.AppDetails = s.appDetailsForPath(sp.Path)
|
|
view.FSInfo = system.GetFSInfo(sp.Path)
|
|
view.AppCount = len(view.AppDetails)
|
|
if di := system.GetDiskUsage(sp.Path); di != nil {
|
|
view.DiskInfo = di
|
|
}
|
|
// Detect USB for safe disconnect button
|
|
if view.FSInfo != nil && view.FSInfo.Device != "" {
|
|
view.IsUSB = system.IsUSBDevice(view.FSInfo.Device)
|
|
}
|
|
}
|
|
storageViews = append(storageViews, view)
|
|
}
|
|
data["StoragePaths"] = storageViews
|
|
|
|
// Recovery info for emergency section
|
|
data["RetrievalPassword"] = s.settings.GetRetrievalPassword()
|
|
data["HubURL"] = s.cfg.Hub.URL
|
|
data["SupportEmail"] = "support@felhom.eu"
|
|
data["SupportURL"] = "https://felhom.eu/kapcsolat"
|
|
|
|
// Geo-restriction data
|
|
data["CFConfigured"] = s.cfg.Infrastructure.CFAPIToken != ""
|
|
geo := s.settings.GetGeoRestriction()
|
|
if geo != nil {
|
|
data["GeoEnabled"] = geo.Enabled
|
|
data["GeoAllowedCountries"] = geo.AllowedCountries
|
|
data["GeoAppOverrides"] = geo.AppOverrides
|
|
data["GeoLastSync"] = geo.LastSync
|
|
data["GeoLastError"] = geo.LastSyncError
|
|
} else {
|
|
data["GeoEnabled"] = false
|
|
data["GeoAllowedCountries"] = []string{"HU"}
|
|
data["GeoAppOverrides"] = map[string]interface{}{}
|
|
}
|
|
// Deployed apps for per-app override selector
|
|
var deployedApps []map[string]string
|
|
for _, stack := range s.stackMgr.GetStacks() {
|
|
if !stack.Deployed || s.cfg.IsProtectedStack(stack.Name) {
|
|
continue
|
|
}
|
|
deployedApps = append(deployedApps, map[string]string{
|
|
"Name": stack.Name,
|
|
"Display": stack.Meta.DisplayName,
|
|
})
|
|
}
|
|
data["DeployedApps"] = deployedApps
|
|
|
|
return data
|
|
}
|
|
|
|
func (s *Server) settingsHandler(w http.ResponseWriter, r *http.Request) {
|
|
data := s.settingsData()
|
|
if msg := r.URL.Query().Get("storage_msg"); msg == "success" {
|
|
data["StorageSuccess"] = r.URL.Query().Get("storage_detail")
|
|
}
|
|
s.executeTemplate(w, r, "settings", data)
|
|
}
|
|
|
|
func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request) {
|
|
_ = r.ParseForm()
|
|
currentPassword := r.FormValue("current_password")
|
|
newPassword := r.FormValue("new_password")
|
|
confirmPassword := r.FormValue("confirm_password")
|
|
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] [web] settingsPasswordHandler: password change attempt from %s", r.RemoteAddr)
|
|
}
|
|
|
|
data := s.settingsData()
|
|
|
|
// Validate current password
|
|
effectiveHash := s.effectivePasswordHash()
|
|
if err := bcrypt.CompareHashAndPassword([]byte(effectiveHash), []byte(currentPassword)); err != nil {
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] [web] settingsPasswordHandler: current password mismatch from %s", r.RemoteAddr)
|
|
}
|
|
data["PasswordError"] = "Hibás jelenlegi jelszó"
|
|
s.executeTemplate(w, r, "settings", data)
|
|
return
|
|
}
|
|
|
|
// Validate new password length
|
|
if len(newPassword) < 8 {
|
|
data["PasswordError"] = "A jelszónak legalább 8 karakter hosszúnak kell lennie"
|
|
s.executeTemplate(w, r, "settings", data)
|
|
return
|
|
}
|
|
|
|
// Validate passwords match
|
|
if newPassword != confirmPassword {
|
|
data["PasswordError"] = "A két jelszó nem egyezik"
|
|
s.executeTemplate(w, r, "settings", data)
|
|
return
|
|
}
|
|
|
|
// Generate bcrypt hash
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), 10)
|
|
if err != nil {
|
|
s.logger.Printf("[ERROR] [web] Failed to hash new password: %v", err)
|
|
data["PasswordError"] = "Belső hiba a jelszó mentésekor"
|
|
s.executeTemplate(w, r, "settings", data)
|
|
return
|
|
}
|
|
|
|
// Save to settings.json
|
|
if err := s.settings.SetPasswordHash(string(hash)); err != nil {
|
|
s.logger.Printf("[ERROR] [web] Failed to save password to settings.json: %v", err)
|
|
data["PasswordError"] = "Belső hiba a jelszó mentésekor"
|
|
s.executeTemplate(w, r, "settings", data)
|
|
return
|
|
}
|
|
|
|
s.logger.Printf("[INFO] [web] Password changed via settings page from %s", r.RemoteAddr)
|
|
|
|
// Invalidate all sessions (force re-login)
|
|
s.invalidateAllSessions()
|
|
|
|
// Redirect to login with flash message
|
|
flash := url.QueryEscape("Jelszó sikeresen módosítva. Kérjük, jelentkezzen be az új jelszóval.")
|
|
http.Redirect(w, r, "/login?flash="+flash, http.StatusFound)
|
|
}
|
|
|
|
func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Request) {
|
|
_ = r.ParseForm()
|
|
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] [web] settingsNotificationsHandler: updating notification prefs from %s", r.RemoteAddr)
|
|
}
|
|
|
|
email := strings.TrimSpace(r.FormValue("notification_email"))
|
|
cooldownStr := r.FormValue("cooldown_hours")
|
|
cooldownHours := 6
|
|
if cooldownStr != "" {
|
|
if n, err := fmt.Sscanf(cooldownStr, "%d", &cooldownHours); n != 1 || err != nil {
|
|
cooldownHours = 6
|
|
}
|
|
}
|
|
if cooldownHours < 1 {
|
|
cooldownHours = 1
|
|
}
|
|
if cooldownHours > 168 {
|
|
cooldownHours = 168
|
|
}
|
|
|
|
// Collect enabled events from checkboxes
|
|
var enabledEvents []string
|
|
// Single-event checkboxes
|
|
for _, evt := range []string{
|
|
"backup_failed", "db_dump_failed", "backup_integrity_failed",
|
|
"crossdrive_failed", "storage_disconnected",
|
|
"node_down", "health_critical",
|
|
"storage_reconnected", "health_recovered",
|
|
} {
|
|
if r.FormValue("event_"+evt) == "on" {
|
|
enabledEvents = append(enabledEvents, evt)
|
|
}
|
|
}
|
|
// Compound toggles: one checkbox → two event types
|
|
if r.FormValue("event_disk_alerts") == "on" {
|
|
enabledEvents = append(enabledEvents, "disk_warning", "disk_critical")
|
|
}
|
|
if r.FormValue("event_expected_missed") == "on" {
|
|
enabledEvents = append(enabledEvents, "expected_backup_missed", "expected_dbdump_missed")
|
|
}
|
|
|
|
prefs := &settings.NotificationPrefs{
|
|
Email: email,
|
|
EnabledEvents: enabledEvents,
|
|
CooldownHours: cooldownHours,
|
|
}
|
|
|
|
if err := s.settings.SetNotificationPrefs(prefs); err != nil {
|
|
s.logger.Printf("[ERROR] [web] Failed to save notification prefs: %v", err)
|
|
data := s.settingsData()
|
|
data["NotificationError"] = "Hiba a beállítások mentésekor"
|
|
s.executeTemplate(w, r, "settings", data)
|
|
return
|
|
}
|
|
|
|
s.logger.Printf("[INFO] [web] Notification preferences updated: email=%s, events=%v", email, enabledEvents)
|
|
|
|
// Sync preferences to hub
|
|
data := s.settingsData()
|
|
if s.notifier != nil && s.notifier.IsEnabled() {
|
|
if err := s.notifier.SyncPreferences(email, enabledEvents, cooldownHours); err != nil {
|
|
s.logger.Printf("[WARN] [web] Failed to sync preferences to hub: %v", err)
|
|
data["NotificationSuccess"] = fmt.Sprintf("Értesítési beállítások mentve (helyi). A központi szinkronizálás sikertelen: %v", err)
|
|
} else {
|
|
data["NotificationSuccess"] = "Értesítési beállítások mentve."
|
|
}
|
|
} else {
|
|
data["NotificationSuccess"] = "Értesítési beállítások mentve."
|
|
}
|
|
s.executeTemplate(w, r, "settings", data)
|
|
}
|
|
|
|
// settingsAppEmailHandler saves the global app-email toggle and starts/stops the on-box
|
|
// SMTP shim to match (no controller restart needed).
|
|
func (s *Server) settingsAppEmailHandler(w http.ResponseWriter, r *http.Request) {
|
|
_ = r.ParseForm()
|
|
enabled := r.FormValue("app_email_enabled") == "on" || r.FormValue("app_email_enabled") == "true"
|
|
fromName := strings.TrimSpace(r.FormValue("app_email_from_name"))
|
|
|
|
data := s.settingsData()
|
|
if err := s.settings.SetAppEmail(enabled, fromName); err != nil {
|
|
s.logger.Printf("[ERROR] [web] Failed to save app-email toggle: %v", err)
|
|
data["AppEmailError"] = "Hiba az alkalmazás-email beállítás mentésekor"
|
|
s.executeTemplate(w, r, "settings", data)
|
|
return
|
|
}
|
|
// Reconcile the shim's running state with the new toggle.
|
|
if s.mailShim != nil {
|
|
if err := s.mailShim.Apply(enabled); err != nil {
|
|
s.logger.Printf("[ERROR] [web] app-email shim could not be %s: %v", map[bool]string{true: "started", false: "stopped"}[enabled], err)
|
|
data = s.settingsData()
|
|
data["AppEmailError"] = "A beállítás elmentve, de az email-szolgáltatás indítása nem sikerült."
|
|
s.executeTemplate(w, r, "settings", data)
|
|
return
|
|
}
|
|
}
|
|
s.logger.Printf("[INFO] [web] App-email globally %s (from_name=%q)", map[bool]string{true: "enabled", false: "disabled"}[enabled], fromName)
|
|
data = s.settingsData()
|
|
if enabled {
|
|
data["AppEmailSuccess"] = "Alkalmazás-email bekapcsolva. Kapcsold be az egyes alkalmazásoknál is, ahol email-küldést szeretnél."
|
|
} else {
|
|
data["AppEmailSuccess"] = "Alkalmazás-email kikapcsolva."
|
|
}
|
|
s.executeTemplate(w, r, "settings", data)
|
|
}
|
|
|
|
func (s *Server) settingsNotificationsTestHandler(w http.ResponseWriter, r *http.Request) {
|
|
data := s.settingsData()
|
|
|
|
if s.notifier == nil {
|
|
data["NotificationError"] = "Az értesítések nincsenek bekapcsolva"
|
|
s.executeTemplate(w, r, "settings", data)
|
|
return
|
|
}
|
|
|
|
err := s.notifier.SendTest()
|
|
if err != nil {
|
|
s.logger.Printf("[ERROR] [web] Test notification failed: %v", err)
|
|
data["NotificationError"] = fmt.Sprintf("Teszt email küldése sikertelen: %v", err)
|
|
s.executeTemplate(w, r, "settings", data)
|
|
return
|
|
}
|
|
|
|
data["NotificationSuccess"] = "Teszt email elküldve."
|
|
s.executeTemplate(w, r, "settings", data)
|
|
}
|
|
|
|
// --- Storage path management handlers ---
|
|
|
|
func (s *Server) countAppsUsingPath(storagePath string) int {
|
|
count := 0
|
|
for _, stack := range s.stackMgr.GetStacks() {
|
|
if !stack.Deployed {
|
|
continue
|
|
}
|
|
if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil {
|
|
if appCfg.Env["HDD_PATH"] == storagePath {
|
|
count++
|
|
}
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
func (s *Server) appsUsingPath(storagePath string) []string {
|
|
return appsUsingPathIn(s.stackMgr.GetStacks(), s.stackMgr.LoadAppConfigByName, storagePath)
|
|
}
|
|
|
|
// missingStorageLabel reports whether a deployed app's HDD_PATH resolves to an UNAVAILABLE registry
|
|
// path (decommissioned, disconnected, or no longer registered) and returns its human label. An app
|
|
// with no HDD_PATH (SSD-resident) is never "missing".
|
|
func (s *Server) missingStorageLabel(hddPath string) (string, bool) {
|
|
if hddPath == "" {
|
|
return "", false
|
|
}
|
|
for _, sp := range s.settings.GetStoragePaths() {
|
|
if sp.Path == hddPath {
|
|
if sp.Decommissioned || sp.Disconnected {
|
|
return s.settings.GetStorageLabel(hddPath), true
|
|
}
|
|
return "", false // present + available
|
|
}
|
|
}
|
|
return s.settings.GetStorageLabel(hddPath), true // not in registry → its drive is gone
|
|
}
|
|
|
|
// missingStorageMap returns stack-name → storage label for every deployed app whose data drive is
|
|
// currently unavailable (drives the "Hiányzó tárhely" dashboard/stacks/app-card indicator).
|
|
func (s *Server) missingStorageMap(list []stacks.Stack) map[string]string {
|
|
out := map[string]string{}
|
|
for _, st := range list {
|
|
if !st.Deployed {
|
|
continue
|
|
}
|
|
if cfg := s.stackMgr.LoadAppConfigByName(st.Name); cfg != nil {
|
|
if label, missing := s.missingStorageLabel(cfg.Env["HDD_PATH"]); missing {
|
|
out[st.Name] = label
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// appsUsingPathIn is the pure core of appsUsingPath (testable without a live stacks.Manager): the
|
|
// deployed apps whose data dir (app.yaml HDD_PATH) is exactly storagePath, by display name. This is
|
|
// the "name the apps that break" list for the type-to-confirm wipe/eject UI.
|
|
func appsUsingPathIn(allStacks []stacks.Stack, loadCfg func(string) *stacks.AppConfig, storagePath string) []string {
|
|
var names []string
|
|
for _, stack := range allStacks {
|
|
if !stack.Deployed {
|
|
continue
|
|
}
|
|
if appCfg := loadCfg(stack.Name); appCfg != nil {
|
|
if appCfg.Env["HDD_PATH"] == storagePath {
|
|
names = append(names, stack.Meta.DisplayName)
|
|
}
|
|
}
|
|
}
|
|
return names
|
|
}
|
|
|
|
func (s *Server) appDetailsForPath(storagePath string) []StorageAppDetail {
|
|
var details []StorageAppDetail
|
|
for _, stack := range s.stackMgr.GetStacks() {
|
|
if !stack.Deployed {
|
|
continue
|
|
}
|
|
appCfg := s.stackMgr.LoadAppConfigByName(stack.Name)
|
|
if appCfg == nil {
|
|
continue
|
|
}
|
|
hddPath := appCfg.Env["HDD_PATH"]
|
|
if hddPath != storagePath {
|
|
continue
|
|
}
|
|
detail := StorageAppDetail{
|
|
Name: stack.Meta.DisplayName,
|
|
Stack: stack.Meta.Slug,
|
|
}
|
|
// Try to get data size from the storage subdirectory
|
|
appDataDir := backup.AppDataDir(storagePath, stack.Name)
|
|
if fi, err := os.Stat(appDataDir); err == nil && fi.IsDir() {
|
|
detail.SizeHuman = dirSizeHuman(appDataDir)
|
|
}
|
|
details = append(details, detail)
|
|
}
|
|
return details
|
|
}
|
|
|
|
// dirSizeHuman returns a human-readable size for a directory.
|
|
func dirSizeHuman(path string) string {
|
|
var total int64
|
|
filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {
|
|
if err != nil || info.IsDir() {
|
|
return nil
|
|
}
|
|
total += info.Size()
|
|
return nil
|
|
})
|
|
const (
|
|
KB = 1024
|
|
MB = KB * 1024
|
|
GB = MB * 1024
|
|
)
|
|
switch {
|
|
case total >= GB:
|
|
return fmt.Sprintf("%.1f GB", float64(total)/float64(GB))
|
|
case total >= MB:
|
|
return fmt.Sprintf("%.1f MB", float64(total)/float64(MB))
|
|
case total >= KB:
|
|
return fmt.Sprintf("%.1f KB", float64(total)/float64(KB))
|
|
default:
|
|
return fmt.Sprintf("%d B", total)
|
|
}
|
|
}
|
|
|
|
func formatFreeSpace(gb float64) string {
|
|
if gb >= 1000 {
|
|
return fmt.Sprintf("%.1f TB", gb/1024)
|
|
}
|
|
return fmt.Sprintf("%.1f GB", gb)
|
|
}
|
|
|
|
func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Request) {
|
|
_ = r.ParseForm()
|
|
|
|
path := filepath.Clean(r.FormValue("storage_path"))
|
|
label := strings.TrimSpace(r.FormValue("storage_label"))
|
|
isDefault := r.FormValue("storage_default") == "true"
|
|
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] [web] settingsStorageAddHandler: path=%s label=%q default=%v from %s", path, label, isDefault, r.RemoteAddr)
|
|
}
|
|
|
|
if label == "" {
|
|
label = settings.InferStorageLabel(path)
|
|
}
|
|
|
|
data := s.settingsData()
|
|
|
|
// 1. Exists and is directory
|
|
fi, err := os.Stat(path)
|
|
if err != nil || !fi.IsDir() {
|
|
data["StorageError"] = "Az útvonal nem létezik vagy nem mappa."
|
|
s.executeTemplate(w, r, "settings", data)
|
|
return
|
|
}
|
|
|
|
// 2. Is mount point
|
|
if !system.IsMountPoint(path) {
|
|
data["StorageError"] = "Ez az útvonal nem külön csatlakoztatott meghajtó. Adatok az SSD-re kerülnének!"
|
|
s.executeTemplate(w, r, "settings", data)
|
|
return
|
|
}
|
|
|
|
// 3. Writable
|
|
if !system.IsWritable(path) {
|
|
data["StorageError"] = "Az útvonal nem írható."
|
|
s.executeTemplate(w, r, "settings", data)
|
|
return
|
|
}
|
|
|
|
// 4. No overlap with existing paths
|
|
for _, existing := range s.settings.GetStoragePaths() {
|
|
if system.PathsOverlap(path, existing.Path) {
|
|
data["StorageError"] = fmt.Sprintf("Az útvonal átfedi a már regisztrált %s útvonalat.", existing.Path)
|
|
s.executeTemplate(w, r, "settings", data)
|
|
return
|
|
}
|
|
}
|
|
|
|
// 5. Soft warning if not under /mnt/
|
|
if !strings.HasPrefix(path, "/mnt/") {
|
|
s.logger.Printf("[WARN] [web] Storage path %s is not under /mnt/ — unusual but allowed", path)
|
|
}
|
|
|
|
sp := settings.StoragePath{
|
|
Path: path,
|
|
Label: label,
|
|
IsDefault: isDefault,
|
|
Schedulable: true,
|
|
AddedAt: time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
|
|
if err := s.settings.AddStoragePath(sp); err != nil {
|
|
s.logger.Printf("[ERROR] [web] Failed to add storage path: %v", err)
|
|
data["StorageError"] = "Hiba a mentés során."
|
|
s.executeTemplate(w, r, "settings", data)
|
|
return
|
|
}
|
|
|
|
s.logger.Printf("[INFO] [web] Storage path added: %s (%s)", path, label)
|
|
go s.SyncFileBrowserMounts()
|
|
http.Redirect(w, r, "/settings?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló sikeresen hozzáadva: "+path), http.StatusFound)
|
|
}
|
|
|
|
func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Request) {
|
|
_ = r.ParseForm()
|
|
path := r.FormValue("storage_path")
|
|
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] [web] settingsStorageRemoveHandler: path=%s from %s", path, r.RemoteAddr)
|
|
}
|
|
|
|
data := s.settingsData()
|
|
|
|
// Check: apps using this path
|
|
apps := s.appsUsingPath(path)
|
|
if len(apps) > 0 {
|
|
data["StorageError"] = fmt.Sprintf("Nem törölhető: az alábbi alkalmazások használják: %s", strings.Join(apps, ", "))
|
|
s.executeTemplate(w, r, "settings", data)
|
|
return
|
|
}
|
|
|
|
// Check: cannot remove default
|
|
for _, sp := range s.settings.GetStoragePaths() {
|
|
if sp.Path == path && sp.IsDefault {
|
|
data["StorageError"] = "Az alapértelmezett adattároló nem törölhető."
|
|
s.executeTemplate(w, r, "settings", data)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Check: last path
|
|
if len(s.settings.GetStoragePaths()) <= 1 {
|
|
data["StorageError"] = "Az utolsó adattároló nem törölhető."
|
|
s.executeTemplate(w, r, "settings", data)
|
|
return
|
|
}
|
|
|
|
if err := s.settings.RemoveStoragePath(path); err != nil {
|
|
data["StorageError"] = "Hiba a törlés során."
|
|
s.executeTemplate(w, r, "settings", data)
|
|
return
|
|
}
|
|
|
|
s.logger.Printf("[INFO] [web] Storage path removed: %s", path)
|
|
// Sync FileBrowser mounts after storage path removal
|
|
go s.SyncFileBrowserMounts()
|
|
http.Redirect(w, r, "/settings?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló eltávolítva: "+path), http.StatusFound)
|
|
}
|
|
|
|
func (s *Server) settingsStorageDefaultHandler(w http.ResponseWriter, r *http.Request) {
|
|
_ = r.ParseForm()
|
|
path := r.FormValue("storage_path")
|
|
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] [web] settingsStorageDefaultHandler: path=%s from %s", path, r.RemoteAddr)
|
|
}
|
|
|
|
if err := s.settings.SetDefaultStoragePath(path); err != nil {
|
|
s.logger.Printf("[ERROR] [web] Failed to set default storage path: %v", err)
|
|
http.Redirect(w, r, "/settings", http.StatusFound)
|
|
return
|
|
}
|
|
s.logger.Printf("[INFO] [web] Default storage path set to %s", path)
|
|
http.Redirect(w, r, "/settings?storage_msg=success&storage_detail="+url.QueryEscape("Alapértelmezett adattároló beállítva: "+path), http.StatusFound)
|
|
}
|
|
|
|
func (s *Server) settingsStorageSchedulableHandler(w http.ResponseWriter, r *http.Request) {
|
|
_ = r.ParseForm()
|
|
path := r.FormValue("storage_path")
|
|
schedulable := r.FormValue("schedulable") == "true"
|
|
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] [web] settingsStorageSchedulableHandler: path=%s schedulable=%v from %s", path, schedulable, r.RemoteAddr)
|
|
}
|
|
|
|
if err := s.settings.SetSchedulable(path, schedulable); err != nil {
|
|
s.logger.Printf("[ERROR] [web] Failed to update schedulable: %v", err)
|
|
http.Redirect(w, r, "/settings", http.StatusFound)
|
|
return
|
|
}
|
|
s.logger.Printf("[INFO] [web] Storage schedulable updated: %s → %v", path, schedulable)
|
|
http.Redirect(w, r, "/settings?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló állapot módosítva: "+path), http.StatusFound)
|
|
}
|
|
|
|
func (s *Server) settingsStorageLabelHandler(w http.ResponseWriter, r *http.Request) {
|
|
_ = r.ParseForm()
|
|
path := r.FormValue("storage_path")
|
|
label := strings.TrimSpace(r.FormValue("storage_label"))
|
|
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] [web] settingsStorageLabelHandler: path=%s label=%q from %s", path, label, r.RemoteAddr)
|
|
}
|
|
|
|
if label == "" || len(label) > 50 {
|
|
data := s.settingsData()
|
|
data["StorageError"] = "A megnevezés nem lehet üres és legfeljebb 50 karakter."
|
|
s.executeTemplate(w, r, "settings", data)
|
|
return
|
|
}
|
|
|
|
if err := s.settings.SetStorageLabel(path, label); err != nil {
|
|
s.logger.Printf("[ERROR] [web] Failed to set storage label: %v", err)
|
|
data := s.settingsData()
|
|
data["StorageError"] = "Hiba a megnevezés mentésekor."
|
|
s.executeTemplate(w, r, "settings", data)
|
|
return
|
|
}
|
|
|
|
s.logger.Printf("[INFO] [web] Storage label updated: %s → %q", path, label)
|
|
http.Redirect(w, r, "/settings?storage_msg=success&storage_detail="+url.QueryEscape("Megnevezés módosítva: "+label), http.StatusFound)
|
|
}
|
|
|
|
// SyncFileBrowserMounts regenerates FileBrowser's docker-compose.yml and config.yaml
|
|
// with volume mounts and sources for all registered storage paths, then recreates the container.
|
|
func (s *Server) SyncFileBrowserMounts() {
|
|
s.syncFileBrowserMounts(false)
|
|
}
|
|
|
|
// SyncFileBrowserMountsReset is like SyncFileBrowserMounts but resets the FileBrowser
|
|
// database when sources change. Use only after restore — normal operations should use
|
|
// SyncFileBrowserMounts to preserve user accounts, permissions, and share links.
|
|
func (s *Server) SyncFileBrowserMountsReset() {
|
|
s.syncFileBrowserMounts(true)
|
|
}
|
|
|
|
// skipFileBrowserPath reports whether a registered storage path should be skipped this FileBrowser
|
|
// sync pass: an EXTERNAL drive path (under StableParentDir) that is not currently a live mountpoint is
|
|
// detached, so its userdata skeleton must not be created (would land on the rootfs) and it must not be
|
|
// mounted into FileBrowser until it returns. System/local paths (not under StableParentDir) are never
|
|
// skipped. Pure + isMount-injected for testability.
|
|
func skipFileBrowserPath(path string, isMount func(string) bool) bool {
|
|
return strings.HasPrefix(path, StableParentDir+"/") && !isMount(path)
|
|
}
|
|
|
|
func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
|
|
// Prevent concurrent syncs — multiple callers can race on the same files (H5 fix).
|
|
s.fileBrowserMu.Lock()
|
|
defer s.fileBrowserMu.Unlock()
|
|
|
|
stackDir := "/opt/docker/stacks/filebrowser"
|
|
composePath := stackDir + "/docker-compose.yml"
|
|
|
|
// Check if FileBrowser stack exists
|
|
if _, err := os.Stat(composePath); os.IsNotExist(err) {
|
|
s.logger.Printf("[WARN] [web] FileBrowser stack not found at %s — skipping mount sync", composePath)
|
|
return
|
|
}
|
|
|
|
// Get all active storage paths
|
|
paths := s.settings.GetStoragePaths()
|
|
|
|
// Use domain from controller config
|
|
domain := s.cfg.Customer.Domain
|
|
if domain == "" {
|
|
s.logger.Printf("[WARN] [web] Cannot sync FileBrowser mounts — customer domain not configured")
|
|
return
|
|
}
|
|
|
|
// Build volume mount lines. SCOPE to the drive's `userdata/` subtree (v0.66.0): the customer
|
|
// browses ONLY userdata — app internals (appdata/) and the recovery units + Tier 2 copies
|
|
// (backups/) are NOT mounted into FileBrowser. userdata is owned group 1000 mode 2775 (setgid),
|
|
// and FileBrowser runs as uid 1000 → it can create folders + upload files (the old appdata mount
|
|
// was guest-root 0755 → permission-denied). Pre-create the full skeleton with the convention.
|
|
var storageMounts []string
|
|
for _, sp := range paths {
|
|
mountName := filepath.Base(sp.Path) // "/mnt/hdd_1" → "hdd_1"
|
|
// Drive-absent gate: an external drive path that isn't currently a live mountpoint is detached —
|
|
// don't create its userdata skeleton (would write onto the rootfs) and don't mount it into
|
|
// FileBrowser this pass. It returns on the next sync after reconnect. Matches planDriveGates'
|
|
// external-only rule (system paths, not under StableParentDir, are never skipped).
|
|
if skipFileBrowserPath(sp.Path, system.IsMountPoint) {
|
|
s.logger.Printf("[INFO] [web] FileBrowser: drive %s not mounted — skipping userdata skeleton", sp.Path)
|
|
continue
|
|
}
|
|
if err := appbackup.EnsureUserdataSkeleton(sp.Path); err != nil {
|
|
s.logger.Printf("[WARN] [web] FileBrowser: could not ensure userdata skeleton on %s: %v", sp.Path, err)
|
|
}
|
|
userdataSrc := appbackup.UserdataDir(sp.Path)
|
|
line := fmt.Sprintf(" - %s:/srv/%s", userdataSrc, mountName)
|
|
storageMounts = append(storageMounts, line)
|
|
}
|
|
|
|
// Generate and write config.yaml (sources + sidebar entries per drive)
|
|
configPath := stackDir + "/config.yaml"
|
|
fbConfig := generateFileBrowserConfig(paths)
|
|
|
|
// Capture the current on-disk content BEFORE any writes, so we can detect whether this sync
|
|
// actually changes anything (F2). The integrations' ReapplyConfigForTarget edits config.yaml
|
|
// after we write it, so the recreate decision is made AFTER the writes against the final files.
|
|
oldConfig, _ := os.ReadFile(configPath)
|
|
oldCompose, _ := os.ReadFile(composePath)
|
|
|
|
// 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 := string(oldConfig) != fbConfig
|
|
|
|
if err := os.WriteFile(configPath, []byte(fbConfig), 0644); err != nil {
|
|
s.logger.Printf("[ERROR] [web] Failed to write FileBrowser config: %v", err)
|
|
return
|
|
}
|
|
|
|
// Re-apply active integrations into config.yaml (before container restart)
|
|
if im := s.integrationMgr.Load(); im != nil {
|
|
im.ReapplyConfigForTarget("filebrowser")
|
|
}
|
|
|
|
// Generate and write compose (includes config.yaml mount)
|
|
compose := generateFileBrowserCompose(domain, storageMounts)
|
|
if err := os.WriteFile(composePath, []byte(compose), 0644); err != nil {
|
|
s.logger.Printf("[ERROR] [web] Failed to write FileBrowser compose: %v", err)
|
|
return
|
|
}
|
|
|
|
// Read back the FINAL content (post-integrations) to decide whether a recreate is warranted (F2):
|
|
// a controller restart or a no-op storage sync must NOT bounce the customer's file UI when nothing
|
|
// actually changed. The recreate only happens when config.yaml or the compose file truly differ.
|
|
finalConfig, _ := os.ReadFile(configPath)
|
|
finalCompose, _ := os.ReadFile(composePath)
|
|
changed := fbNeedsRecreate(oldConfig, finalConfig, oldCompose, finalCompose)
|
|
|
|
// If sources changed and caller requested a DB reset (restore flow),
|
|
// nuke the data volume so FileBrowser re-reads config.yaml from scratch.
|
|
// Normal operations skip this to preserve user accounts, permissions, and share links.
|
|
if sourcesChanged && resetDBOnChange {
|
|
s.logger.Printf("[INFO] [web] FileBrowser sources changed — resetting database (restore mode)")
|
|
resetCtx, resetCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer resetCancel()
|
|
stop := exec.CommandContext(resetCtx, "docker", "compose", "down", "-v")
|
|
stop.Dir = stackDir
|
|
if out, err := stop.CombinedOutput(); err != nil {
|
|
s.logger.Printf("[WARN] [web] FileBrowser down -v: %s — %v", strings.TrimSpace(string(out)), err)
|
|
}
|
|
changed = true // a DB reset removed the container — it must be recreated
|
|
}
|
|
|
|
// Bring FileBrowser up. H16: 60s timeout to prevent hanging indefinitely. Only force-recreate when
|
|
// something actually changed; otherwise a plain `up -d` just ensures it's running without a bounce.
|
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
defer cancel()
|
|
args := []string{"compose", "up", "-d", "--remove-orphans"}
|
|
if changed {
|
|
args = []string{"compose", "up", "-d", "--force-recreate", "--remove-orphans"}
|
|
}
|
|
cmd := exec.CommandContext(ctx, "docker", args...)
|
|
cmd.Dir = stackDir
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
s.logger.Printf("[ERROR] [web] Failed to bring up FileBrowser: %s — %v", string(out), err)
|
|
} else if changed {
|
|
s.logger.Printf("[INFO] [web] FileBrowser mounts synced (recreated) — %d storage path(s), config updated", len(paths))
|
|
} else {
|
|
s.logger.Printf("[INFO] [web] FileBrowser sync — no config/compose change, ensured running without recreate (%d storage path(s))", len(paths))
|
|
}
|
|
}
|
|
|
|
// fbNeedsRecreate reports whether the FileBrowser container must be force-recreated: true when either
|
|
// the config.yaml or the compose file content changed between the pre-sync and post-sync state. On the
|
|
// first-ever run the old files are empty → differs from the freshly generated content → true (creates
|
|
// it). Pure, so syncFileBrowserMounts' recreate decision is unit-testable without shelling to docker.
|
|
func fbNeedsRecreate(oldConfig, newConfig, oldCompose, newCompose []byte) bool {
|
|
return !bytes.Equal(oldConfig, newConfig) || !bytes.Equal(oldCompose, newCompose)
|
|
}
|
|
|
|
// generateFileBrowserCompose returns a FileBrowser docker-compose.yml string with the given domain
|
|
// and storage volume-mount lines. Delegates to internal/infra (the single source of truth — so the
|
|
// pinned image and the base-infra bring-up path can never diverge).
|
|
func generateFileBrowserCompose(domain string, storageMounts []string) string {
|
|
return infra.RenderFileBrowserCompose(domain, storageMounts)
|
|
}
|
|
|
|
// generateFileBrowserConfig returns a FileBrowser Quantum config.yaml with a separate source per
|
|
// registered storage path. Delegates to internal/infra (single source of truth).
|
|
func generateFileBrowserConfig(paths []settings.StoragePath) string {
|
|
return infra.RenderFileBrowserConfig(paths)
|
|
}
|