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