4a9aea647b
- Cross-drive now copies DB dumps (_db/) and config (_config/) alongside user data - restic cross-drive includes config dir + full DB dump dir - UI: per-tier rows (1. mentés / 2. mentés) instead of per-layer (DB/Konfig/Data) - UI: BackupContents label shows what each tier protects (DB + Konfig + Adatok) - UI: rsync backups show browsable indicator (📁) - Cleanup: removed unused filterSnapshotsByPaths + pathCovers from router.go Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1315 lines
40 KiB
Go
1315 lines
40 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"
|
|
)
|
|
|
|
|
|
// 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
|
|
}
|
|
|
|
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
|
|
|
|
// 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
|
|
}
|
|
|
|
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()
|
|
// 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)
|
|
|
|
// 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")
|
|
}
|
|
|
|
// 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)
|
|
|
|
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 {
|
|
if !app.HasHDDData {
|
|
continue
|
|
}
|
|
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
|
|
}
|
|
} else {
|
|
data["Backup"] = nil
|
|
}
|
|
|
|
s.render(w, "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
|
|
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 (only for apps with HDD data)
|
|
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
|
|
|
|
// 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"
|
|
}
|
|
}
|
|
|
|
var rows []AppBackupRow
|
|
for _, app := range status.AppDataInfo {
|
|
hasDB := dbStacks[app.StackName] || app.HasDBDump
|
|
|
|
// 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,
|
|
StorageLabel: app.StorageLabel,
|
|
HDDSizeHuman: app.HDDSizeHuman,
|
|
BackupContents: contents,
|
|
|
|
Tier1LastRun: tier1LastRun,
|
|
Tier1LastStatus: tier1LastStatus,
|
|
Tier1DBStatus: tier1DBStatus,
|
|
}
|
|
|
|
// Default status = auto (no user data, just config)
|
|
row.Status = "auto"
|
|
row.StatusText = "Automatikus mentés"
|
|
|
|
if app.HasHDDData {
|
|
cfg, hasCfg := crossConfigs[app.StackName]
|
|
|
|
if !hasCfg || cfg == nil || !cfg.Enabled {
|
|
// HDD data backed up via nightly restic (mandatory), but no second copy
|
|
row.Tier2Configured = false
|
|
row.Status = "yellow"
|
|
row.StatusText = "Nincs második másolat (csak helyi mentés)"
|
|
} 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"
|
|
case "error":
|
|
row.Tier2StatusBadge = "Hiba"
|
|
row.Status = "yellow"
|
|
row.StatusText = "Utolsó mentés sikertelen"
|
|
case "running":
|
|
row.Tier2StatusBadge = "Fut..."
|
|
default:
|
|
row.Tier2StatusBadge = "—"
|
|
}
|
|
|
|
// Destination health check
|
|
if cfg.Enabled && 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 {
|
|
row.Status = "yellow"
|
|
row.StatusText = "Figyelmeztetés"
|
|
}
|
|
row.Warnings = append(row.Warnings, err.Error())
|
|
} else if row.Status != "yellow" {
|
|
row.Status = "green"
|
|
row.StatusText = "Mentés rendben"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
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,
|
|
IsMounted: system.IsMountPoint(sp.Path),
|
|
AppDetails: s.appDetailsForPath(sp.Path),
|
|
FSInfo: system.GetFSInfo(sp.Path),
|
|
}
|
|
view.AppCount = len(view.AppDetails)
|
|
if di := system.GetDiskUsage(sp.Path); di != nil {
|
|
view.DiskInfo = di
|
|
}
|
|
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)
|
|
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
|
|
// with volume mounts for all registered storage paths, then recreates the container.
|
|
func (s *Server) syncFileBrowserMounts() {
|
|
composePath := "/opt/docker/stacks/filebrowser/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 := "/opt/docker/stacks/filebrowser/.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 compose from template
|
|
compose := generateFileBrowserCompose(domain, storageMounts)
|
|
|
|
// Write compose
|
|
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 = filepath.Dir(composePath)
|
|
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)", 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%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)
|
|
}
|