987e915bf2
New /launcher page: a grid of large tappable tiles, one per openable deployed app (subdomain presence is the single openability criterion, shared with the Megnyitás button via the extracted Server.subdomainMap helper). Colored tiles (deterministic slug color or .felhom.yml brand_color), white glyph/monogram fallback, target=_blank links for operational apps, greyed unclickable tiles for stopped ones. First sidebar item; / stays the Vezérlőpult. Universal app placeholder: new AppPlaceholderSVG served at /static/app-placeholder.svg, now the default FallbackIcon on app_list_row so a logo-less app shows a placeholder instead of visibility:hidden. Brand mark is never an app placeholder. New Metadata.BrandColor; new funcmap tileColor/initial. 10 new test functions + 4 red-proofs. No agent coupling; MinAgent unchanged.
2360 lines
92 KiB
Go
2360 lines
92 KiB
Go
package web
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"fmt"
|
||
"log"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||
"gitea.dooplex.hu/admin/felhom-controller/internal/crypto"
|
||
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
|
||
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
|
||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
||
"golang.org/x/crypto/bcrypt"
|
||
)
|
||
|
||
// protectedStackSubdomains maps programmatically managed protected stacks
|
||
// to their well-known subdomains (these stacks have no .felhom.yml or app.yaml).
|
||
var protectedStackSubdomains = map[string]string{
|
||
"filebrowser": "files",
|
||
}
|
||
|
||
// StorageBarInfo holds data for rendering a storage usage bar on dashboard/monitoring.
|
||
type StorageBarInfo struct {
|
||
Label string // e.g., "USB HDD 1TB", "SYS Storage 350G"
|
||
Path string // e.g., "/mnt/hdd_1"
|
||
Purpose string // Hungarian explanation of what this drive holds (monitoring page)
|
||
TotalGB float64
|
||
UsedGB float64
|
||
Percent float64
|
||
Disconnected bool
|
||
}
|
||
|
||
// storageBarPurpose is the Hungarian description for the registered user-data drives shown in the
|
||
// monitoring "Tárolók kapacitása" list. These are all external/user-data drives (the agent's
|
||
// system/PBS storage is not in the controller's storage-path registry), matching the user-data
|
||
// purpose text on the storage-management page (Phase 4C).
|
||
const storageBarPurpose = "Külső adattároló — a telepített alkalmazások nagy méretű fájljai (média, dokumentumok) ide kerülnek; az adatbázisok a belső SSD-n vannak."
|
||
|
||
// buildStorageBars returns usage bars for all registered storage paths, in a stable order
|
||
// (by path) with a purpose description.
|
||
func (s *Server) buildStorageBars() []StorageBarInfo {
|
||
var bars []StorageBarInfo
|
||
for _, sp := range s.settings.GetStoragePaths() {
|
||
// Skip decommissioned drives — they are no longer in active use
|
||
if sp.Decommissioned {
|
||
continue
|
||
}
|
||
if sp.Disconnected {
|
||
bars = append(bars, StorageBarInfo{
|
||
Label: sp.Label,
|
||
Path: sp.Path,
|
||
Purpose: storageBarPurpose,
|
||
Disconnected: true,
|
||
})
|
||
continue
|
||
}
|
||
di := system.GetDiskUsage(sp.Path)
|
||
if di == nil {
|
||
continue
|
||
}
|
||
bars = append(bars, StorageBarInfo{
|
||
Label: sp.Label,
|
||
Path: sp.Path,
|
||
Purpose: storageBarPurpose,
|
||
TotalGB: di.TotalGB,
|
||
UsedGB: di.UsedGB,
|
||
Percent: di.UsedPercent,
|
||
})
|
||
}
|
||
// Deterministic order regardless of registry insertion order.
|
||
sort.Slice(bars, func(i, j int) bool { return bars[i].Path < bars[j].Path })
|
||
return bars
|
||
}
|
||
|
||
// DeployStoragePath extends StoragePath with free space data for the deploy dropdown.
|
||
type DeployStoragePath struct {
|
||
settings.StoragePath
|
||
FreeHuman string // "234.5 GB"
|
||
FreePercent float64 // 67.5
|
||
}
|
||
|
||
// StorageAppDetail holds info about an app using a specific storage path.
|
||
type StorageAppDetail struct {
|
||
Name string // Display name (e.g., "Immich")
|
||
Stack string // Stack name (for link)
|
||
SizeHuman string // Data size on this path
|
||
}
|
||
|
||
// StoragePathView extends StoragePath with display data for the settings page.
|
||
type StoragePathView struct {
|
||
settings.StoragePath
|
||
DiskInfo *system.DiskUsageInfo
|
||
AppCount int
|
||
IsMounted bool
|
||
AppDetails []StorageAppDetail
|
||
FSInfo *system.FSInfo
|
||
IsUSB bool // true if this is a USB-attached device (safe disconnect available)
|
||
StoppedApps []string // stacks auto-stopped due to disconnect (for restart UI)
|
||
MigratedToLabel string // label of the drive data was migrated to
|
||
HasOtherPaths bool // true if other connected non-decommissioned paths exist
|
||
IsEnrolled bool // enrolled via the wizard (stable /mnt/felhom-drives/ path) — lifecycle is disconnect/decommission, not list-removal
|
||
}
|
||
|
||
func (s *Server) baseData(page, title string) map[string]interface{} {
|
||
data := map[string]interface{}{
|
||
"Page": page,
|
||
"Title": title,
|
||
"CustomerName": s.cfg.Customer.Name,
|
||
"Domain": s.cfg.Customer.Domain,
|
||
"Version": s.version,
|
||
"AuthEnabled": s.authEnabled(),
|
||
"DebugMode": s.isDebug(),
|
||
// Customer-claim arc (v0.122.0, F-4): the transitional legacy-open banner — no password,
|
||
// no code hash yet. Cleared the moment the hub delivers a code hash (gate flips on).
|
||
"ClaimLegacyOpen": s.claimLegacyOpen(),
|
||
}
|
||
if s.alertManager != nil {
|
||
data["Alerts"] = s.alertManager.GetAlerts()
|
||
}
|
||
return data
|
||
}
|
||
|
||
func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
|
||
stackList := s.stackMgr.GetStacks()
|
||
|
||
// Filter to deployed + protected stacks first
|
||
var deployedStacks []stacks.Stack
|
||
for _, st := range stackList {
|
||
if st.Deployed || st.Protected {
|
||
deployedStacks = append(deployedStacks, st)
|
||
}
|
||
}
|
||
|
||
// Count from the DISPLAYED set only
|
||
running, stopped := 0, 0
|
||
for _, st := range deployedStacks {
|
||
switch st.State {
|
||
case stacks.StateRunning, stacks.StateStarting, stacks.StateUnhealthy, stacks.StateRestarting:
|
||
running++
|
||
// R-51: degraded counts with stopped — the dashboard counter answers "how many of my apps
|
||
// work", and a stack with a dead supervised member does not.
|
||
case stacks.StateStopped, stacks.StateExited, stacks.StateDegraded:
|
||
stopped++
|
||
}
|
||
}
|
||
|
||
sysInfo := system.GetInfo(s.primaryHDDPath(), s.cpuCollector)
|
||
|
||
data := s.baseData("dashboard", "Vezérlőpult")
|
||
data["SettingsWarning"] = s.settings.LoadWarning // non-empty if settings.json was recovered from corruption
|
||
data["Stacks"] = deployedStacks
|
||
data["MissingStorage"] = s.missingStorageMap(deployedStacks)
|
||
nw, ns := s.networkStorageWarnings(deployedStacks) // NAS unreachable (recoverable) / guest-side stub (defect)
|
||
data["NetworkWarnings"] = nw
|
||
data["NetworkStubs"] = ns
|
||
data["RunningCount"] = running
|
||
data["StoppedCount"] = stopped
|
||
data["TotalCount"] = len(stackList)
|
||
data["SystemInfo"] = sysInfo
|
||
data["StorageBars"] = s.buildStorageBars()
|
||
|
||
// Backup status
|
||
data["BackupEnabled"] = s.cfg.Backup.Enabled
|
||
if s.backupMgr != nil {
|
||
nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule)
|
||
fullStatus := s.backupMgr.GetFullStatus(nextDBDump)
|
||
data["DBDumpStatus"] = fullStatus.LastDBDump
|
||
// F3 (AUDIT-vacation-remote-ops-2026-07-20): the card's "Utolsó mentés" row branches on
|
||
// .BackupStatus, which was never passed — so the {{if}} arm was unreachable and EVERY box
|
||
// rendered "Még nem futott" regardless of history. *DBDumpStatus nil/non-nil maps exactly
|
||
// onto the template's branch, so a fresh box still reads "Még nem futott" honestly.
|
||
data["BackupStatus"] = fullStatus.LastDBDump
|
||
data["BackupRunning"] = fullStatus.Running
|
||
data["BackupMaxAgeHours"] = s.cfg.Monitoring.Thresholds.BackupMaxAgeHours
|
||
}
|
||
|
||
// Build subdomain map for "Megnyitás" buttons
|
||
data["Subdomains"] = s.subdomainMap(deployedStacks)
|
||
|
||
if s.alertManager != nil {
|
||
data["DiskWarnings"] = s.alertManager.GetInlineAlerts("dashboard")
|
||
}
|
||
|
||
s.executeTemplate(w, r, "dashboard", data)
|
||
}
|
||
|
||
// subdomainMap builds the stack-name → subdomain lookup used by the "Megnyitás" open links (dashboard
|
||
// + Alkalmazások) and the Indítópult launcher. Priority — unchanged from the two inline copies it
|
||
// replaces: the deployed app.yaml SUBDOMAIN env wins, then the .felhom.yml Meta.Subdomain, then the
|
||
// well-known protectedStackSubdomains fallback. A stack with no source of a subdomain gets no entry.
|
||
func (s *Server) subdomainMap(stackList []stacks.Stack) map[string]string {
|
||
subdomains := make(map[string]string)
|
||
for _, stack := range stackList {
|
||
if stack.Deployed {
|
||
if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil {
|
||
if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd != "" {
|
||
subdomains[stack.Name] = sd
|
||
continue
|
||
}
|
||
}
|
||
}
|
||
if stack.Meta.Subdomain != "" {
|
||
subdomains[stack.Name] = stack.Meta.Subdomain
|
||
} else if sd, ok := protectedStackSubdomains[stack.Name]; ok {
|
||
subdomains[stack.Name] = sd
|
||
}
|
||
}
|
||
return subdomains
|
||
}
|
||
|
||
// controllerStackName is the controller's own stack — it IS this dashboard, so it never appears as a
|
||
// launchable app even if a subdomain mapping somehow exists for it.
|
||
const controllerStackName = "felhom-controller"
|
||
|
||
// LauncherApp is one openable app tile for the Indítópult (launcher) page.
|
||
type LauncherApp struct {
|
||
Name string
|
||
DisplayName string
|
||
Slug string
|
||
State stacks.ContainerState
|
||
Subdomain string
|
||
OpenPath string
|
||
BrandColor string
|
||
}
|
||
|
||
// buildLauncherApps builds the sorted launcher tile list from the deployed/protected stacks and the
|
||
// subdomain lookup. A tile exists exactly when a "Megnyitás" button would — i.e. the stack has a
|
||
// subdomain entry — minus the controller itself. Sorted by DisplayName (tie-broken by Name) so the
|
||
// grid order is stable regardless of Go's map iteration order.
|
||
func buildLauncherApps(stackList []stacks.Stack, subdomains map[string]string) []LauncherApp {
|
||
var apps []LauncherApp
|
||
for _, st := range stackList {
|
||
if st.Name == controllerStackName {
|
||
continue
|
||
}
|
||
sd, ok := subdomains[st.Name]
|
||
if !ok || sd == "" {
|
||
continue
|
||
}
|
||
dn := st.Meta.DisplayName
|
||
if dn == "" {
|
||
dn = st.Name
|
||
}
|
||
apps = append(apps, LauncherApp{
|
||
Name: st.Name,
|
||
DisplayName: dn,
|
||
Slug: st.Meta.Slug,
|
||
State: st.State,
|
||
Subdomain: sd,
|
||
OpenPath: st.Meta.OpenPath,
|
||
BrandColor: st.Meta.BrandColor,
|
||
})
|
||
}
|
||
sort.Slice(apps, func(i, j int) bool {
|
||
if apps[i].DisplayName == apps[j].DisplayName {
|
||
return apps[i].Name < apps[j].Name
|
||
}
|
||
return apps[i].DisplayName < apps[j].DisplayName
|
||
})
|
||
return apps
|
||
}
|
||
|
||
// launcherHandler renders the Indítópult: a grid of large tappable tiles, one per openable deployed
|
||
// app (subdomain presence is the single openability criterion — see buildLauncherApps). Behind
|
||
// RequireAuth like every page; the "/" landing page stays the Vezérlőpult.
|
||
func (s *Server) launcherHandler(w http.ResponseWriter, r *http.Request) {
|
||
var eligible []stacks.Stack
|
||
for _, st := range s.stackMgr.GetStacks() {
|
||
if st.Deployed || st.Protected {
|
||
eligible = append(eligible, st)
|
||
}
|
||
}
|
||
data := s.baseData("launcher", "Indítópult")
|
||
data["Apps"] = buildLauncherApps(eligible, s.subdomainMap(eligible))
|
||
s.executeTemplate(w, r, "launcher", data)
|
||
}
|
||
|
||
// visibleCatalogStacks drops templates that are no longer OFFERED for new installs (lifecycle
|
||
// `hidden` or `abandoned`) AND are not deployed on this box.
|
||
//
|
||
// The `Deployed || Protected` half is the load-bearing part: a customer already running an app must
|
||
// keep seeing and managing it, whatever the catalog now says about offering it to new customers.
|
||
// Withdrawing an app must never take a working app away from someone — that is precisely the failure
|
||
// the short-lived `retired/` directory move would have caused, and why lifecycle is a metadata field
|
||
// rather than a deletion.
|
||
//
|
||
// Filtered here, in the handler, rather than in the template: the template already carries five
|
||
// conditional badges per card, and a visibility rule buried among them is a rule nobody can test.
|
||
func visibleCatalogStacks(in []stacks.Stack) []stacks.Stack {
|
||
out := make([]stacks.Stack, 0, len(in))
|
||
for _, st := range in {
|
||
if st.Deployed || st.Protected || st.Meta.CanInstall() {
|
||
out = append(out, st)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (s *Server) stacksHandler(w http.ResponseWriter, r *http.Request) {
|
||
data := s.baseData("stacks", "Alkalmazások")
|
||
allStacks := visibleCatalogStacks(s.stackMgr.GetStacks())
|
||
data["Stacks"] = allStacks
|
||
data["MissingStorage"] = s.missingStorageMap(allStacks)
|
||
nw, ns := s.networkStorageWarnings(allStacks) // NAS unreachable (recoverable) / guest-side stub (defect)
|
||
data["NetworkWarnings"] = nw
|
||
data["NetworkStubs"] = ns
|
||
|
||
// Build storage label lookup for deployed apps
|
||
storageLabels := make(map[string]string) // stack name → storage label
|
||
storagePaths := s.settings.GetStoragePaths()
|
||
for _, stack := range s.stackMgr.GetStacks() {
|
||
if !stack.Deployed {
|
||
continue
|
||
}
|
||
if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil {
|
||
if hddPath := appCfg.Env["HDD_PATH"]; hddPath != "" {
|
||
for _, sp := range storagePaths {
|
||
if sp.Path == hddPath {
|
||
storageLabels[stack.Name] = sp.Label
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
data["StorageLabels"] = storageLabels
|
||
|
||
// Build effective subdomain lookup (stored env > metadata > well-known fallback)
|
||
data["Subdomains"] = s.subdomainMap(s.stackMgr.GetStacks())
|
||
|
||
s.executeTemplate(w, r, "stacks", data)
|
||
}
|
||
|
||
func (s *Server) logsHandler(w http.ResponseWriter, r *http.Request, name string) {
|
||
stack, ok := s.stackMgr.GetStack(name)
|
||
if !ok {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
|
||
logs, err := s.stackMgr.GetLogs(name, 200)
|
||
if err != nil {
|
||
logs = fmt.Sprintf("Hiba a naplók lekérésekor: %v", err)
|
||
}
|
||
|
||
// Raw mode: return plain text for AJAX polling
|
||
if r.URL.Query().Get("raw") == "1" {
|
||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||
fmt.Fprint(w, logs)
|
||
return
|
||
}
|
||
|
||
data := s.baseData("logs", stack.Meta.DisplayName+" — Naplók")
|
||
data["Stack"] = stack
|
||
data["Logs"] = logs
|
||
s.executeTemplate(w, r, "logs", data)
|
||
}
|
||
|
||
func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name string) {
|
||
if s.isDebug() {
|
||
s.logger.Printf("[DEBUG] [web] deployHandler: stack=%s method=%s", name, r.Method)
|
||
}
|
||
meta, appCfg, err := s.stackMgr.GetDeployFields(name)
|
||
if err != nil {
|
||
if s.isDebug() {
|
||
s.logger.Printf("[DEBUG] [web] deployHandler: stack=%s not found: %v", name, err)
|
||
}
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
|
||
stack, _ := s.stackMgr.GetStack(name)
|
||
alreadyDeployed := appCfg != nil && appCfg.Deployed
|
||
|
||
pageTitle := meta.DisplayName + " — Telepítés"
|
||
if alreadyDeployed {
|
||
pageTitle = meta.DisplayName + " — Beállítások"
|
||
}
|
||
data := s.baseData("deploy", pageTitle)
|
||
data["Stack"] = stack
|
||
data["Meta"] = meta
|
||
data["AppConfig"] = appCfg
|
||
data["AlreadyDeployed"] = alreadyDeployed
|
||
data["LogoURL"] = s.cfg.AppLogoURL(meta.Slug)
|
||
data["LogoPNGURL"] = s.cfg.AppLogoPNGURL(meta.Slug)
|
||
data["AppPageURL"] = s.cfg.AppPageURL(meta.Slug)
|
||
data["UserFields"] = meta.UserFacingFields()
|
||
data["AutoFields"] = meta.AutoGeneratedFields()
|
||
// Auto-generated field values: existing values for deployed apps, pre-generated for new deploys
|
||
autoFieldValues := make(map[string]string)
|
||
var decryptedEnv map[string]string
|
||
if appCfg != nil {
|
||
decryptedEnv = crypto.DecryptMap(s.encKey, appCfg.Env)
|
||
}
|
||
if alreadyDeployed && appCfg != nil {
|
||
for _, f := range meta.AutoGeneratedFields() {
|
||
if val, ok := decryptedEnv[f.EnvVar]; ok {
|
||
autoFieldValues[f.EnvVar] = val
|
||
}
|
||
}
|
||
} else if !alreadyDeployed {
|
||
// Pre-generate values so the user sees (and can note down) domain/passwords before deploying.
|
||
// These same values are submitted back in the form and saved to app.yaml.
|
||
if preview, err := s.stackMgr.PreviewDeployValues(name); err == nil {
|
||
autoFieldValues = preview
|
||
}
|
||
}
|
||
data["AutoFieldValues"] = autoFieldValues
|
||
// For deployed apps, pass stored field values (decrypted) so fields show current values
|
||
if alreadyDeployed && decryptedEnv != nil {
|
||
data["DeployedFieldValues"] = decryptedEnv
|
||
}
|
||
// Storage paths with free space info for deploy dropdown
|
||
var deployPaths []DeployStoragePath
|
||
for _, sp := range s.settings.GetSchedulableStoragePaths() {
|
||
dp := DeployStoragePath{StoragePath: sp}
|
||
if di := system.GetDiskUsage(sp.Path); di != nil {
|
||
dp.FreeHuman = formatFreeSpace(di.AvailGB)
|
||
if di.TotalGB > 0 {
|
||
dp.FreePercent = di.AvailGB / di.TotalGB * 100
|
||
}
|
||
}
|
||
deployPaths = append(deployPaths, dp)
|
||
}
|
||
data["StoragePaths"] = deployPaths
|
||
// RCA fix 4: a deployed app's read-only storage select must show the app's STORED HDD_PATH —
|
||
// never the default drive (the pre-fix render selected by IsDefault only, so the settings view
|
||
// lied about where the data lives). When the stored path is no longer schedulable, an extra
|
||
// disabled option names it verbatim rather than silently showing a different storage.
|
||
data["CurrentHDDPath"] = ""
|
||
data["CurrentHDDPathMissing"] = false
|
||
if alreadyDeployed && appCfg != nil {
|
||
if hdd := appCfg.Env["HDD_PATH"]; hdd != "" {
|
||
data["CurrentHDDPath"] = hdd
|
||
inList := false
|
||
for _, dp := range deployPaths {
|
||
if dp.Path == hdd {
|
||
inList = true
|
||
break
|
||
}
|
||
}
|
||
data["CurrentHDDPathMissing"] = !inList
|
||
}
|
||
}
|
||
|
||
// Prevention layer (storage-split): surface the Docker-data volume's reserved-buffer state so the
|
||
// customer sees BEFORE deploying when free space is too low (the API gate also hard-refuses). Only
|
||
// meaningful for a NEW deploy (an existing app's config save doesn't consume fresh image space).
|
||
if !alreadyDeployed {
|
||
if hr := system.GetDockerVolumeHeadroom(); hr.OK {
|
||
data["DockerBelowReserve"] = hr.BelowReserve
|
||
data["DockerFreeHuman"] = formatFreeSpace(hr.AvailGB)
|
||
data["DockerReserveHuman"] = formatFreeSpace(hr.ReserveGB)
|
||
}
|
||
}
|
||
|
||
// Effective subdomain for "Megnyitás" button
|
||
if alreadyDeployed && appCfg != nil {
|
||
if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd != "" {
|
||
data["EffectiveSubdomain"] = sd
|
||
}
|
||
}
|
||
|
||
// Disk-tier storage management (drive info, stale-data cleanup, cross-drive
|
||
// backup) has moved to the host agent (slice 8C); the deploy page no longer
|
||
// renders those sections.
|
||
if alreadyDeployed {
|
||
// App-to-app integrations
|
||
if im := s.integrationMgr.Load(); meta.HasIntegrations() && im != nil {
|
||
data["HasIntegrations"] = true
|
||
data["Integrations"] = im.ListForProvider(meta.Slug)
|
||
}
|
||
|
||
// Geo-restriction per-app data
|
||
geo := s.settings.GetGeoRestriction()
|
||
if geo != nil && geo.Enabled && s.cfg.Infrastructure.CFAPIToken != "" {
|
||
data["GeoGlobalEnabled"] = true
|
||
data["GeoGlobalCountries"] = geo.AllowedCountries
|
||
if ov, ok := geo.AppOverrides[name]; ok {
|
||
data["GeoAppOverride"] = true
|
||
data["GeoAppOverrideCountries"] = ov.AllowedCountries
|
||
} else {
|
||
data["GeoAppOverrideCountries"] = []string{}
|
||
}
|
||
} else {
|
||
data["GeoGlobalCountries"] = []string{}
|
||
data["GeoAppOverrideCountries"] = []string{}
|
||
}
|
||
|
||
// Optional config (metadata providers, etc.)
|
||
if meta.HasOptionalConfig() {
|
||
data["HasOptionalConfig"] = true
|
||
data["OptionalConfig"] = meta.OptionalConfig
|
||
optValues := make(map[string]string)
|
||
if decryptedEnv != nil {
|
||
for _, group := range meta.OptionalConfig {
|
||
for _, field := range group.Fields {
|
||
if val, ok := decryptedEnv[field.EnvVar]; ok {
|
||
optValues[field.EnvVar] = val
|
||
}
|
||
}
|
||
}
|
||
}
|
||
data["CurrentValues"] = optValues
|
||
}
|
||
|
||
// App-email per-app toggle — only for apps that declare an smtp_mapping. Shown with
|
||
// honest context whether or not the global toggle is on.
|
||
if supported, enabled := s.stackMgr.AppEmailStatus(name); supported {
|
||
data["AppEmailSupported"] = true
|
||
data["AppEmailAppOn"] = enabled
|
||
data["AppEmailGlobalOn"] = s.settings.AppEmailEnabled()
|
||
local := meta.SMTPMapping.FromLocal
|
||
if local == "" {
|
||
local = meta.Slug
|
||
}
|
||
domain := "felhom.eu"
|
||
if len(s.cfg.MailRelay.FromDomains) > 0 && s.cfg.MailRelay.FromDomains[0] != "" {
|
||
domain = s.cfg.MailRelay.FromDomains[0]
|
||
}
|
||
data["AppEmailFromAddress"] = local + "@" + domain
|
||
}
|
||
}
|
||
|
||
// Memory info for deploy page (only for non-deployed apps)
|
||
if !alreadyDeployed {
|
||
memInfo := map[string]interface{}{"Available": false}
|
||
totalMB, usedMB, memErr := system.GetMemoryMB()
|
||
if memErr == nil {
|
||
reservedMB := s.cfg.System.ReservedMemoryMB
|
||
usableMB := totalMB - reservedMB
|
||
newReqMB := stacks.ParseMemoryMB(meta.Resources.MemRequest)
|
||
afterMB := usedMB + newReqMB
|
||
percent := 0
|
||
if usableMB > 0 {
|
||
percent = afterMB * 100 / usableMB
|
||
}
|
||
usedPercent := 0
|
||
if usableMB > 0 {
|
||
usedPercent = usedMB * 100 / usableMB
|
||
}
|
||
|
||
// Overcommit warning still uses declared limits
|
||
_, committedLimitMB := s.stackMgr.CommittedMemory()
|
||
newLimitMB := stacks.ParseMemoryMB(meta.Resources.MemLimit)
|
||
afterLimitMB := committedLimitMB + newLimitMB
|
||
|
||
memInfo["Available"] = true
|
||
memInfo["TotalMB"] = totalMB
|
||
memInfo["ReservedMB"] = reservedMB
|
||
memInfo["UsableMB"] = usableMB
|
||
memInfo["UsedMB"] = usedMB
|
||
memInfo["NewRequestMB"] = newReqMB
|
||
memInfo["AfterMB"] = afterMB
|
||
memInfo["Percent"] = percent
|
||
memInfo["UsedPercent"] = usedPercent
|
||
memInfo["Blocked"] = newReqMB > 0 && afterMB > usableMB
|
||
memInfo["OvercommitWarn"] = newLimitMB > 0 && afterLimitMB > totalMB
|
||
}
|
||
data["MemoryInfo"] = memInfo
|
||
}
|
||
|
||
// Flash messages from cross-drive backup save redirect
|
||
if flash := r.URL.Query().Get("flash"); flash != "" {
|
||
data["FlashSuccess"] = flash
|
||
}
|
||
if flashErr := r.URL.Query().Get("flash_error"); flashErr != "" {
|
||
data["FlashError"] = flashErr
|
||
}
|
||
|
||
s.executeTemplate(w, r, "deploy", data)
|
||
}
|
||
|
||
func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug string) {
|
||
var found *stacks.Stack
|
||
for _, stack := range s.stackMgr.GetStacks() {
|
||
if stack.Meta.Slug == slug {
|
||
found = &stack
|
||
break
|
||
}
|
||
}
|
||
if found == nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
|
||
// Determine effective subdomain (stored env > metadata fallback)
|
||
effectiveSubdomain := found.Meta.Subdomain
|
||
if appCfg := s.stackMgr.LoadAppConfigByName(found.Name); appCfg != nil {
|
||
if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd != "" {
|
||
effectiveSubdomain = sd
|
||
}
|
||
}
|
||
|
||
data := s.baseData("stacks", found.Meta.DisplayName)
|
||
data["Stack"] = found
|
||
data["Meta"] = found.Meta
|
||
data["AppInfo"] = found.Meta.AppInfo
|
||
data["HasAppInfo"] = found.Meta.HasAppInfo()
|
||
data["EffectiveSubdomain"] = effectiveSubdomain
|
||
|
||
// Initial auto-generated login (e.g. Crafty writes a random admin password to a file at first
|
||
// boot). Read it live from the container so the customer doesn't have to dig through logs. Only
|
||
// for deployed apps that declare an initial_credentials spec; hidden when unreadable.
|
||
if found.Deployed && found.Meta.InitialCreds != nil {
|
||
if creds, err := s.stackMgr.ReadInitialCredentials(found.Name); err != nil {
|
||
s.logger.Printf("[WARN] [web] initial-creds for %s: %v", found.Name, err)
|
||
} else if creds != nil && creds.Available {
|
||
data["InitialCreds"] = creds
|
||
}
|
||
}
|
||
|
||
// Per-app migration (B1): offer to move this app's data to another connected drive (≠ current).
|
||
if found.Deployed {
|
||
current := ""
|
||
if appCfg := s.stackMgr.LoadAppConfigByName(found.Name); appCfg != nil {
|
||
current = appCfg.Env["HDD_PATH"]
|
||
}
|
||
var targets []settings.StoragePath
|
||
for _, sp := range s.settings.GetStoragePaths() {
|
||
if sp.Path == current || sp.Decommissioned || sp.Disconnected || !sp.Schedulable {
|
||
continue
|
||
}
|
||
targets = append(targets, sp)
|
||
}
|
||
data["MigrateTargets"] = targets
|
||
data["MigrateCurrent"] = current
|
||
if label, missing := s.missingStorageLabel(current); missing {
|
||
data["MissingStorageLabel"] = label
|
||
}
|
||
}
|
||
|
||
s.executeTemplate(w, r, "app_info", data)
|
||
}
|
||
|
||
func (s *Server) monitoringHandler(w http.ResponseWriter, r *http.Request) {
|
||
data := s.baseData("monitoring", "Rendszermonitor")
|
||
data["SystemInfo"] = system.GetInfo(s.primaryHDDPath(), s.cpuCollector)
|
||
data["StorageBars"] = s.buildStorageBars()
|
||
|
||
if s.alertManager != nil {
|
||
data["Alerts"] = s.alertManager.GetAlerts()
|
||
data["DiskWarnings"] = s.alertManager.GetInlineAlerts("monitoring")
|
||
}
|
||
|
||
// Hub connection status section
|
||
data["HubEnabled"] = s.cfg.Hub.Enabled && s.cfg.Hub.URL != ""
|
||
data["HubURL"] = s.cfg.Hub.URL
|
||
data["CustomerID"] = s.cfg.Customer.ID
|
||
|
||
if s.hubPushStatusFn != nil {
|
||
ps := s.hubPushStatusFn()
|
||
data["HubLastAttempt"] = ps.LastAttempt
|
||
data["HubLastSuccess"] = ps.LastSuccess
|
||
data["HubLastError"] = ps.LastError
|
||
data["HubConsecutiveFailures"] = ps.Consecutive
|
||
// Connected if last success was within 2x the push interval (or 30min default)
|
||
connected := !ps.LastSuccess.IsZero() && time.Since(ps.LastSuccess) < 30*time.Minute
|
||
data["HubConnected"] = connected
|
||
}
|
||
|
||
// Legacy ping status section (still shown for backward compat during transition)
|
||
data["MonitoringEnabled"] = s.cfg.Monitoring.Enabled
|
||
if s.cfg.Monitoring.Enabled {
|
||
pings := []map[string]interface{}{
|
||
{"Label": "Eletjel (Heartbeat)", "Icon": "heartbeat", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.Heartbeat), "Schedule": "5 percenkent"},
|
||
{"Label": "Rendszer allapot", "Icon": "system", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.SystemHealth), "Schedule": "5 percenkent"},
|
||
{"Label": "Adatbazis mentes", "Icon": "db", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.DBDump), "Schedule": "Naponta " + s.cfg.Backup.DBDumpSchedule},
|
||
{"Label": "Biztonsagi mentes", "Icon": "backup", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.Backup), "Schedule": "Naponta " + s.cfg.Backup.ResticSchedule},
|
||
{"Label": "Mentes integritas", "Icon": "integrity", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.BackupIntegrity), "Schedule": "Hetente (vasarnap)"},
|
||
}
|
||
allConfigured := true
|
||
for _, p := range pings {
|
||
if !p["Configured"].(bool) {
|
||
allConfigured = false
|
||
break
|
||
}
|
||
}
|
||
data["PingStatus"] = pings
|
||
data["AllPingsConfigured"] = allConfigured
|
||
}
|
||
|
||
s.executeTemplate(w, r, "monitoring", data)
|
||
}
|
||
|
||
// isPingConfigured returns true if a healthcheck ping UUID is non-empty and not a placeholder.
|
||
func isPingConfigured(uuid string) bool {
|
||
return uuid != "" && !strings.HasPrefix(uuid, "CHANGEME")
|
||
}
|
||
|
||
// backupsCommonData builds what every backups sub-page shares (v0.124.0 IA split): the page
|
||
// chrome + the backup full-status with the redirect flash. Backup stays nil (empty-state) when
|
||
// the manager is absent. Each page handler adds ONLY the data its sections render — no
|
||
// duplicated computation across the four pages.
|
||
func (s *Server) backupsCommonData(page, title string, r *http.Request) map[string]interface{} {
|
||
data := s.baseData(page, title)
|
||
if s.backupMgr == nil {
|
||
data["Backup"] = nil
|
||
return data
|
||
}
|
||
nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule)
|
||
fullStatus := s.backupMgr.GetFullStatus(nextDBDump)
|
||
|
||
// Pass flash messages from query params (set by redirect handlers)
|
||
if flash := r.URL.Query().Get("flash"); flash != "" {
|
||
fullStatus.FlashSuccess = flash
|
||
}
|
||
if flashErr := r.URL.Query().Get("flash_error"); flashErr != "" {
|
||
fullStatus.FlashError = flashErr
|
||
}
|
||
data["Backup"] = fullStatus
|
||
return data
|
||
}
|
||
|
||
// backupsOffboxData adds the offbox target + per-app toggle state (the remote, apps and restore
|
||
// pages all render some of it: status card / toggle list / tier-3 rows / restore-to-verify).
|
||
func (s *Server) backupsOffboxData(data map[string]interface{}) {
|
||
offboxTgt := s.settings.GetOffboxTarget()
|
||
data["Offbox"] = offboxTgt
|
||
data["OffboxConfigured"] = s.backupMgr != nil && s.backupMgr.OffboxConfigured()
|
||
// R-70 (v0.161.0): hub-managed offsite is enabled in controller.yaml — the descriptor exists
|
||
// and the apply-bridge WILL configure the target automatically. The empty state must say so
|
||
// instead of the bare "nincs beállítva" (a box stuck pre-apply looked identical to one that
|
||
// was never provisioned — DIAG-f10: that ambiguity hid a burned credential for 2 days).
|
||
data["OffsiteHubEnabled"] = s.cfg != nil && s.cfg.Offsite.Enabled
|
||
offboxApps := s.buildOffboxApps()
|
||
data["OffboxApps"] = offboxApps
|
||
// Zero-toggle hint (take-two obs.): configured + escrowed but no app selected — nothing is
|
||
// actually covered by the offsite leg until the customer toggles at least one.
|
||
offboxToggled := 0
|
||
for _, a := range offboxApps {
|
||
if a.Enabled {
|
||
offboxToggled++
|
||
}
|
||
}
|
||
data["OffboxToggledCount"] = offboxToggled
|
||
// Part E (v0.126.0): the LastWarning DISPLAY pick — never a state mutation.
|
||
if offboxTgt != nil {
|
||
data["OffboxWarningDisplay"] = offboxWarningDisplay(offboxTgt.LastWarning, offboxToggled)
|
||
} else {
|
||
data["OffboxWarningDisplay"] = ""
|
||
}
|
||
// SLICE 4 soft-quota usage bar (rendered only when a quota is set — shared model).
|
||
data["OffboxQuotaPct"] = backup.OffboxQuotaPercent(offboxTgt)
|
||
// 3a: per-app "config+DB only" note set — apps whose enlarged push the quota gate blocked last run.
|
||
blocked := map[string]bool{}
|
||
if offboxTgt != nil {
|
||
for _, a := range offboxTgt.EnlargedBlocked {
|
||
blocked[a] = true
|
||
}
|
||
}
|
||
data["OffboxBlockedSet"] = blocked
|
||
}
|
||
|
||
// offboxStaleWarningMarker is the substring the zero-toggled offbox run writes into
|
||
// LastWarning (backup/offbox.go); offboxSelectionChangedLine replaces it once the
|
||
// selection has moved on.
|
||
const (
|
||
offboxStaleWarningMarker = "nincs mentésre jelölt alkalmazás"
|
||
offboxSelectionChangedLine = "A kijelölés módosult az utolsó futás óta — a következő távoli mentés már tartalmazza."
|
||
)
|
||
|
||
// offboxWarningDisplay picks what the Távoli mentés page shows for the persisted
|
||
// Offbox.LastWarning. A zero-toggled run records "Sikeres — nincs mentésre jelölt
|
||
// alkalmazás…"; once the customer HAS toggled apps that line is stale and misleading —
|
||
// replace it with the honest "selection changed, the next run covers it" note.
|
||
// Pure display logic: the persisted LastWarning is never touched, and every other
|
||
// warning (quota, partial failure) passes through verbatim.
|
||
func offboxWarningDisplay(lastWarning string, toggledCount int) string {
|
||
if toggledCount >= 1 && strings.Contains(lastWarning, offboxStaleWarningMarker) {
|
||
return offboxSelectionChangedLine
|
||
}
|
||
return lastWarning
|
||
}
|
||
|
||
// backupsHandler renders the Áttekintés page: storage overview, whole-guest Rendszermentés and
|
||
// the status stat cards.
|
||
func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
|
||
data := s.backupsCommonData("backups", "Biztonsági mentés", r)
|
||
|
||
// System info for storage overview bars
|
||
data["SystemInfo"] = system.GetInfo(s.primaryHDDPath(), s.cpuCollector)
|
||
data["StorageBars"] = s.buildStorageBars()
|
||
|
||
// Whole-guest backup view (agent-sourced, read-only) for the "Rendszermentés" section.
|
||
data["GuestBackup"] = s.loadGuestBackup(r.Context())
|
||
|
||
if fullStatus, ok := data["Backup"].(*backup.FullBackupStatus); ok && fullStatus != nil {
|
||
// DB-section state — honest messaging for embedded-DB-only boxes (SQLite etc.):
|
||
// "dumps" (real dumps) | "pending" (discovered, first run tonight) | "embedded".
|
||
data["DBSectionState"] = dbSectionState(len(fullStatus.DiscoveredDBs), len(fullStatus.DumpFiles))
|
||
}
|
||
|
||
s.executeTemplate(w, r, "backups", data)
|
||
}
|
||
|
||
// escrowCeremonyGraceWindow (v0.138.0) bounds how long the "awaiting hub confirmation" card is
|
||
// shown after a completed escrow ceremony before it degrades to the "confirmation did not arrive"
|
||
// warning. Two report cycles (2×15m) + slack — long enough for the normal report-ACK confirm
|
||
// (Phase-0 verdict A: the demo confirmed on the next ACK ~14m out), short enough that a genuinely
|
||
// stuck ceremony never renders as an indefinite wait.
|
||
const escrowCeremonyGraceWindow = 35 * time.Minute
|
||
|
||
// offboxCeremonyWaitState classifies the post-ceremony wait for the remote page's escrow card:
|
||
// awaiting (stamped, within the grace window, still pending) vs timedOut (stamped, past the window,
|
||
// still pending). Both false when escrowed, unstamped, or the timestamp is unparseable — fail to the
|
||
// plain pending CTA rather than render a phantom wait.
|
||
func offboxCeremonyWaitState(t *settings.OffboxTarget) (awaiting, timedOut bool) {
|
||
if t == nil || t.EscrowState == "escrowed" || t.CeremonyCompletedAt == "" {
|
||
return false, false
|
||
}
|
||
ts, err := time.Parse(time.RFC3339, t.CeremonyCompletedAt)
|
||
if err != nil {
|
||
return false, false
|
||
}
|
||
if time.Since(ts) >= escrowCeremonyGraceWindow {
|
||
return false, true
|
||
}
|
||
return true, false
|
||
}
|
||
|
||
// backupsRemoteHandler renders the Távoli mentés page: the Felhom-offsite status card, the
|
||
// participation toggles and the manual-target form.
|
||
func (s *Server) backupsRemoteHandler(w http.ResponseWriter, r *http.Request) {
|
||
data := s.backupsCommonData("backups-remote", "Biztonsági mentés — Távoli mentés", r)
|
||
s.backupsOffboxData(data)
|
||
// Escrow ceremony card states (v0.127.0): the Scenario-F stale flag + the agent version gate.
|
||
data["EscrowStale"] = s.escrowStale()
|
||
agentVer := ""
|
||
if agent, err := s.escrowAgentConn(); err == nil {
|
||
agentVer = agent.AgentVersion()
|
||
}
|
||
data["EscrowAgentOK"] = escrowAgentSupported(agentVer)
|
||
// v0.138.0: the post-ceremony "awaiting hub confirmation" card (and its timeout degrade) —
|
||
// bridges the report-cycle gap between the ceremony and the auto-confirmer's pending→escrowed flip.
|
||
awaiting, timedOut := offboxCeremonyWaitState(s.settings.GetOffboxTarget())
|
||
data["OffboxCeremonyAwaiting"] = awaiting
|
||
data["OffboxCeremonyTimedOut"] = timedOut
|
||
// v0.142.0 offsite-repo continuity: the orphan card + the auto-refresh (Part C) trigger.
|
||
data["OffboxOrphaned"] = s.backupMgr != nil && s.backupMgr.OffboxOrphaned()
|
||
s.executeTemplate(w, r, "backups_remote", data)
|
||
}
|
||
|
||
// backupsAppsHandler renders the Alkalmazások page: schedule, databases and the per-app
|
||
// 1./2./3. tier rows.
|
||
func (s *Server) backupsAppsHandler(w http.ResponseWriter, r *http.Request) {
|
||
data := s.backupsCommonData("backups-apps", "Biztonsági mentés — Alkalmazások", r)
|
||
s.backupsOffboxData(data) // the tier-3 rows render $.Offbox status
|
||
|
||
if fullStatus, ok := data["Backup"].(*backup.FullBackupStatus); ok && fullStatus != nil {
|
||
// Enrich AppDataInfo with storage labels
|
||
storagePaths := s.settings.GetStoragePaths()
|
||
for i := range fullStatus.AppDataInfo {
|
||
app := &fullStatus.AppDataInfo[i]
|
||
if len(app.HDDPaths) > 0 {
|
||
hddPath := app.HDDPaths[0].HostPath
|
||
// Match HDD path prefix against registered storage paths
|
||
for _, sp := range storagePaths {
|
||
if strings.HasPrefix(hddPath, sp.Path) {
|
||
app.StorageLabel = sp.Label
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Build unified per-app backup rows for the app-data backup UI.
|
||
// Disk-tier (cross-drive / restic) backup has moved to the host agent.
|
||
data["AppBackupRows"] = s.buildAppBackupRows(fullStatus)
|
||
data["DBSectionState"] = dbSectionState(len(fullStatus.DiscoveredDBs), len(fullStatus.DumpFiles))
|
||
}
|
||
|
||
s.executeTemplate(w, r, "backups_apps", data)
|
||
}
|
||
|
||
// backupsRestoreHandler renders the Visszaállítás page: the restore panel, the offbox
|
||
// restore-to-verify list and the .fab export/import loop.
|
||
func (s *Server) backupsRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||
data := s.backupsCommonData("backups-restore", "Biztonsági mentés — Visszaállítás", r)
|
||
s.backupsOffboxData(data) // restore-to-verify lists the offbox-toggled apps
|
||
// Full-restore two-step reveal (§7.2): after the size+headroom prepare step, offboxRestoreHandler
|
||
// redirects here with the app + human size so the confirm section can show the size BEFORE starting.
|
||
if fp := strings.TrimSpace(r.URL.Query().Get("full_prep")); fp != "" {
|
||
data["FullPrepApp"] = fp
|
||
data["FullPrepSize"] = r.URL.Query().Get("full_size")
|
||
}
|
||
// Per-app place-to-live availability (a completed full scratch exists → offer the merge action).
|
||
ready := map[string]bool{}
|
||
if s.backupMgr != nil {
|
||
if apps, ok := data["OffboxApps"].([]OffboxAppRow); ok {
|
||
for _, a := range apps {
|
||
if a.Enabled && s.backupMgr.OffboxFullScratchReady(a.Name) {
|
||
ready[a.Name] = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
data["OffboxScratchReady"] = ready
|
||
// R-43: the same prepared scratch also enables the TRUE restore (files + database). Its confirm
|
||
// has to state what the pair actually IS — how old the DB half is, whether the two halves even
|
||
// come from the same run, and whether the dump looks customer-empty — because a restore is the
|
||
// one operation whose result the customer cannot inspect until after committing to it.
|
||
pairs := map[string]backup.OffsitePairInfo{}
|
||
if s.backupMgr != nil {
|
||
for name := range ready {
|
||
pairs[name] = s.backupMgr.OffsiteScratchPair(name)
|
||
}
|
||
}
|
||
data["OffboxPairInfo"] = pairs
|
||
// R-7b: the shares source is not an app — it has no per-app toggle and no recovery unit — so it
|
||
// gets its own restore entry rather than a synthetic row in OffboxApps (which would also make it
|
||
// appear in the per-app offsite TOGGLE list on /backups/remote, where it does not belong).
|
||
if s.backupMgr != nil {
|
||
data["SharesRestoreOffered"] = s.settings != nil && len(s.settings.GetSMBShares()) > 0
|
||
data["SharesScratchReady"] = s.backupMgr.SharesScratchReady()
|
||
data["SharesDisplayName"] = backup.SharesDisplayName
|
||
}
|
||
// v0.147.0 (4a): what verification restores have actually left on disk. Previously nothing on any
|
||
// page listed these, so they accumulated invisibly and the only way to find them was SSH.
|
||
if s.backupMgr != nil {
|
||
data["OffsiteRestoreCopies"] = s.backupMgr.ListOffsiteRestoreCopies()
|
||
}
|
||
s.executeTemplate(w, r, "backups_restore", data)
|
||
}
|
||
|
||
// OffboxAppRow is one deployed app's off-box toggle state for the backups page.
|
||
type OffboxAppRow struct {
|
||
Name string
|
||
DisplayName string
|
||
Slug string // catalog slug for the shared app-row icon (logoURL)
|
||
Enabled bool
|
||
}
|
||
|
||
// buildOffboxApps lists deployed, non-protected apps with their off-box toggle state.
|
||
func (s *Server) buildOffboxApps() []OffboxAppRow {
|
||
var out []OffboxAppRow
|
||
if s.stackMgr == nil {
|
||
return out
|
||
}
|
||
for _, st := range s.stackMgr.GetStacks() {
|
||
if !st.Deployed || st.Protected {
|
||
continue
|
||
}
|
||
dn := st.Meta.DisplayName
|
||
if dn == "" {
|
||
dn = st.Name
|
||
}
|
||
out = append(out, OffboxAppRow{Name: st.Name, DisplayName: dn, Slug: st.Meta.Slug, Enabled: s.settings.IsAppOffbox(st.Name)})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// AppBackupRow holds per-tier backup information for one app on the backup page.
|
||
type AppBackupRow struct {
|
||
StackName string
|
||
DisplayName string
|
||
Slug string // catalog slug for the aligned header icon (logoURL)
|
||
Status string // "green", "yellow", "red", "auto"
|
||
StatusText string // short Hungarian tooltip
|
||
|
||
// App characteristics
|
||
HasHDDData bool
|
||
HasDB bool
|
||
HasVolumeData bool
|
||
StorageLabel string
|
||
HDDSizeHuman string
|
||
|
||
// What this app's backup contains (for display)
|
||
// e.g., "DB + Konfiguráció + Adatok", "DB + Konfiguráció", "Konfiguráció"
|
||
BackupContents string
|
||
|
||
// Tier 1: Nightly backup (always exists)
|
||
Tier1LastRun string // RFC3339 time of the newest recovery-unit artifact ("" = no unit yet)
|
||
Tier1LastStatus string // "ok", "error", ""
|
||
Tier1DBStatus string // "ok", "error", "" — separate DB dump status for warning
|
||
|
||
// Tier 2: Cross-drive backup (configurable for all apps)
|
||
Tier2Configured bool
|
||
Tier2Dest string // destination label
|
||
Tier2Schedule string // "Naponta", "Hetente"
|
||
Tier2LastRun string
|
||
Tier2LastStatus string // "ok", "error", "running", ""
|
||
Tier2LastError string
|
||
Tier2LastWarning string // 3b: capture-gap / state-only notice on an otherwise-ok run
|
||
Tier2StatusBadge string // "Sikeres", "Hiba", "Fut...", "—"
|
||
Tier2SizeHuman string
|
||
|
||
// Drive disconnected — app's home drive is currently disconnected
|
||
DriveDisconnected bool
|
||
// Tier2 destination drive is currently disconnected (backup paused, not failed)
|
||
Tier2DestDisconnected bool
|
||
// Tier2 destination drive is inactive (Schedulable=false, backup paused)
|
||
Tier2DestInactive bool
|
||
// Tier2UserDisabled — customer turned Tier 2 off for this app from the config panel.
|
||
Tier2UserDisabled bool
|
||
|
||
// Tier 3: Off-box (NAS) restic-SFTP backup — the off-site 3-2-1 leg.
|
||
OffboxEnabled bool // this app toggled for off-box inclusion (IsAppOffbox)
|
||
Tier3State string // "unconfigured" | "off" | "escrow_pending" | "active" (see tier3State)
|
||
|
||
// Warnings accumulated for this app
|
||
Warnings []string
|
||
}
|
||
|
||
// buildAppBackupRows constructs one AppBackupRow per deployed app for the backup page.
|
||
// Disk-tier (cross-drive / restic) backup has moved to the host agent; this now
|
||
// reflects only the app-data backup (DB dumps + Docker-volume tars).
|
||
func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackupRow {
|
||
// Build DB stack lookup
|
||
dbStacks := make(map[string]bool)
|
||
for _, db := range status.DiscoveredDBs {
|
||
dbStacks[db.StackName] = true
|
||
}
|
||
for _, f := range status.DumpFiles {
|
||
dbStacks[f.StackName] = true
|
||
}
|
||
|
||
tier1DBStatus := ""
|
||
if status.LastDBDump != nil {
|
||
if status.LastDBDump.Success {
|
||
tier1DBStatus = "ok"
|
||
} else {
|
||
tier1DBStatus = "error"
|
||
}
|
||
}
|
||
|
||
// Build disconnected paths set for drive-disconnected detection
|
||
disconnectedPaths := make(map[string]bool)
|
||
for _, dp := range s.settings.GetDisconnectedPaths() {
|
||
disconnectedPaths[dp.Path] = true
|
||
}
|
||
|
||
// Off-box (Tier 3) globals — resolved once for all rows. The per-app state is
|
||
// (global configured) × (global escrow) × (per-app toggle); see tier3State.
|
||
offboxConfigured := s.backupMgr != nil && s.backupMgr.OffboxConfigured()
|
||
offboxEscrowState := ""
|
||
if t := s.settings.GetOffboxTarget(); t != nil {
|
||
offboxEscrowState = t.EscrowState
|
||
}
|
||
|
||
var rows []AppBackupRow
|
||
for _, app := range status.AppDataInfo {
|
||
hasDB := dbStacks[app.StackName] || app.HasDBDump
|
||
|
||
// Check if this app's home drive is disconnected
|
||
driveDisconnected := false
|
||
if app.HasHDDData && len(app.HDDPaths) > 0 {
|
||
for dp := range disconnectedPaths {
|
||
for _, hp := range app.HDDPaths {
|
||
if strings.HasPrefix(hp.HostPath, dp+"/") || hp.HostPath == dp {
|
||
driveDisconnected = true
|
||
break
|
||
}
|
||
}
|
||
if driveDisconnected {
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
// Build backup contents label
|
||
var parts []string
|
||
if hasDB {
|
||
parts = append(parts, "DB")
|
||
}
|
||
parts = append(parts, "Konfig")
|
||
if app.HasHDDData || app.HasVolumeData {
|
||
parts = append(parts, "Adatok")
|
||
}
|
||
contents := strings.Join(parts, " + ")
|
||
|
||
slug := ""
|
||
if s.stackMgr != nil {
|
||
if st, ok := s.stackMgr.GetStack(app.StackName); ok {
|
||
slug = st.Meta.Slug
|
||
}
|
||
}
|
||
|
||
row := AppBackupRow{
|
||
StackName: app.StackName,
|
||
DisplayName: app.DisplayName,
|
||
Slug: slug,
|
||
HasHDDData: app.HasHDDData,
|
||
HasDB: hasDB,
|
||
HasVolumeData: app.HasVolumeData,
|
||
DriveDisconnected: driveDisconnected,
|
||
StorageLabel: app.StorageLabel,
|
||
HDDSizeHuman: app.HDDSizeHuman,
|
||
BackupContents: contents,
|
||
Tier1DBStatus: tier1DBStatus,
|
||
}
|
||
|
||
// Tier 1: newest recovery-unit artifact time. ListRestorePoints does the correct
|
||
// per-drive namespace resolution (do NOT re-derive paths — the offbox DIAG trap).
|
||
// A known stack with no unit yet returns an empty list → no fabricated time.
|
||
if s.backupMgr != nil {
|
||
if pts, ok := s.backupMgr.ListRestorePoints(app.StackName); ok && len(pts) > 0 {
|
||
row.Tier1LastRun = pts[0].Time
|
||
// A unit exists: green unless the DB dump failed (keep tier1DBStatus as the source).
|
||
if status.LastDBDump != nil && !status.LastDBDump.Success {
|
||
row.Tier1LastStatus = "error"
|
||
} else {
|
||
row.Tier1LastStatus = "ok"
|
||
}
|
||
}
|
||
}
|
||
|
||
// Tier 3: off-box (NAS) inclusion state for this app.
|
||
row.OffboxEnabled = s.settings.IsAppOffbox(app.StackName)
|
||
row.Tier3State = tier3State(offboxConfigured, row.OffboxEnabled, offboxEscrowState)
|
||
|
||
// Status dot — app-data backup status
|
||
row.Status = "green"
|
||
row.StatusText = "Alkalmazás-adat mentés rendben"
|
||
if hasDB && tier1DBStatus == "error" {
|
||
row.Status = "yellow"
|
||
row.StatusText = "Adatbázis mentés sikertelen"
|
||
}
|
||
|
||
// Tier 2 (off-drive copy) status, from the config the Tier 2 runner persists.
|
||
if cd := s.settings.GetCrossDriveConfig(app.StackName); cd != nil {
|
||
row.Tier2UserDisabled = cd.UserDisabled
|
||
if cd.UserDisabled {
|
||
// Customer turned Tier 2 off — show nothing more; the panel button still appears.
|
||
} else if cd.LastStatus == "no_target" {
|
||
// Auto Tier 2 found no off-drive target — surface the honest reason (no silent gap).
|
||
row.Tier2Configured = false
|
||
row.Tier2StatusBadge = "Nincs 2. meghajtó"
|
||
row.Tier2LastError = cd.LastError
|
||
} else if cd.Enabled {
|
||
row.Tier2Configured = true
|
||
row.Tier2Dest = tier2DestLabel(cd.DestinationPath, s.cfg.Paths.SystemDataPath)
|
||
row.Tier2Schedule = "Naponta"
|
||
row.Tier2LastRun = cd.LastRun
|
||
row.Tier2LastStatus = cd.LastStatus
|
||
row.Tier2LastError = cd.LastError
|
||
row.Tier2LastWarning = cd.LastWarning
|
||
row.Tier2SizeHuman = cd.LastSizeHuman
|
||
switch cd.LastStatus {
|
||
case "ok":
|
||
row.Tier2StatusBadge = "Sikeres"
|
||
case "error":
|
||
row.Tier2StatusBadge = "Hiba"
|
||
case "running":
|
||
row.Tier2StatusBadge = "Fut..."
|
||
default:
|
||
row.Tier2StatusBadge = "—"
|
||
}
|
||
}
|
||
}
|
||
|
||
rows = append(rows, row)
|
||
}
|
||
return rows
|
||
}
|
||
|
||
// tier2DestLabel renders a friendly destination label for the "2. mentés" card. A destination under
|
||
// the system-data path is the internal SSD (DB/config only); otherwise it's an external drive.
|
||
func tier2DestLabel(destPath, systemDataPath string) string {
|
||
if systemDataPath != "" && strings.HasPrefix(destPath, systemDataPath) {
|
||
return "belső SSD (csak DB/konfiguráció)"
|
||
}
|
||
return filepath.Base(strings.TrimSuffix(destPath, "/"+backup.FelhomDataDir))
|
||
}
|
||
|
||
func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||
_ = r.ParseForm()
|
||
|
||
stackName := r.FormValue("stack_name")
|
||
snapshotID := r.FormValue("snapshot_id")
|
||
|
||
if s.isDebug() {
|
||
s.logger.Printf("[DEBUG] [web] backupRestoreHandler: stack=%s snapshot=%s from %s", stackName, snapshotID, r.RemoteAddr)
|
||
}
|
||
|
||
if stackName == "" || snapshotID == "" {
|
||
http.Redirect(w, r, "/backups/restore?flash_error=Hi%C3%A1nyz%C3%B3+param%C3%A9terek", http.StatusFound)
|
||
return
|
||
}
|
||
// F2 (defense-in-depth): a stack name is a single segment, never a path. Reject traversal before any
|
||
// restore work — never let it reach RestoreFromRecoveryUnit.
|
||
if !validStackName(stackName) {
|
||
s.logger.Printf("[WARN] [web] restore rejected: invalid stack_name %q from %s", stackName, r.RemoteAddr)
|
||
http.Redirect(w, r, "/backups/restore?flash_error=%C3%89rv%C3%A9nytelen+alkalmaz%C3%A1sn%C3%A9v", http.StatusFound)
|
||
return
|
||
}
|
||
|
||
if s.backupMgr == nil {
|
||
http.Redirect(w, r, "/backups/restore?flash_error=Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva", http.StatusFound)
|
||
return
|
||
}
|
||
// Part B: restore is a long SYNCHRONOUS op (F4 — through cloudflared's hard 100s cap the customer
|
||
// got an error page while it silently succeeded). Fast-path refuse a concurrent op, then run it in
|
||
// a BACKGROUND goroutine (survives the request; the poll banner shows progress → result).
|
||
if s.backupMgr.IsRunning() {
|
||
http.Redirect(w, r, "/backups/restore?flash_error="+url.QueryEscape("Egy mentési/visszaállítási művelet már fut."), http.StatusFound)
|
||
return
|
||
}
|
||
s.logger.Printf("[WARN] [web] Restore requested (async): stack=%s, snapshot=%s from %s", stackName, snapshotID, r.RemoteAddr)
|
||
s.backupMgr.BeginRestoreOp("restore", stackName)
|
||
go func() {
|
||
start := time.Now()
|
||
// Phase 2b: restore from the app's recovery unit (recovers secrets from the guest, fail-closed
|
||
// on an unrecoverable data-encrypting key; falls back to volume-only restore if no unit exists).
|
||
if err := s.backupMgr.RestoreFromRecoveryUnit(stackName); err != nil {
|
||
s.logger.Printf("[ERROR] [web] Restore failed (async): stack=%s: %v", stackName, err)
|
||
s.backupMgr.EndRestoreOp(false, "Visszaállítás sikertelen: "+err.Error())
|
||
return
|
||
}
|
||
s.logger.Printf("[INFO] [web] Restore completed (async): stack=%s in %s", stackName, time.Since(start))
|
||
s.backupMgr.EndRestoreOp(true, stackName+" visszaállítva ("+snapshotID+").")
|
||
}()
|
||
http.Redirect(w, r, "/backups/restore?flash="+url.QueryEscape("Visszaállítás elindult — az állapot itt frissül."), http.StatusFound)
|
||
}
|
||
|
||
// backupTier2RestoreHandler (C2, closes F2) restores an app's MISSING user files in place from its
|
||
// recorded Tier-2 copy — additive-only: existing live files are never overwritten and nothing is
|
||
// ever deleted (see backup.RestoreTier2Files). Same handler shape as backupRestoreHandler.
|
||
func (s *Server) backupTier2RestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||
_ = r.ParseForm()
|
||
stackName := r.FormValue("stack_name")
|
||
|
||
if stackName == "" {
|
||
http.Redirect(w, r, "/backups/apps?flash_error=Hi%C3%A1nyz%C3%B3+param%C3%A9terek", http.StatusFound)
|
||
return
|
||
}
|
||
// Same F2-defense as the unit restore: a stack name is a single segment, never a path.
|
||
if !validStackName(stackName) {
|
||
s.logger.Printf("[WARN] [web] Tier-2 file restore rejected: invalid stack_name %q from %s", stackName, r.RemoteAddr)
|
||
http.Redirect(w, r, "/backups/apps?flash_error=%C3%89rv%C3%A9nytelen+alkalmaz%C3%A1sn%C3%A9v", http.StatusFound)
|
||
return
|
||
}
|
||
if s.backupMgr == nil {
|
||
http.Redirect(w, r, "/backups/apps?flash_error=Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva", http.StatusFound)
|
||
return
|
||
}
|
||
// Part B (same async shape as backupRestoreHandler): fast-path refuse, then background goroutine.
|
||
if s.backupMgr.IsRunning() {
|
||
http.Redirect(w, r, "/backups/apps?flash_error="+url.QueryEscape("Egy mentési/visszaállítási művelet már fut."), http.StatusFound)
|
||
return
|
||
}
|
||
s.logger.Printf("[WARN] [web] Tier-2 file restore requested (async): stack=%s from %s", stackName, r.RemoteAddr)
|
||
s.backupMgr.BeginRestoreOp("tier2-restore", stackName)
|
||
go func() {
|
||
n, err := s.backupMgr.RestoreTier2Files(stackName)
|
||
if err != nil {
|
||
s.logger.Printf("[ERROR] [web] Tier-2 file restore failed (async): stack=%s: %v", stackName, err)
|
||
s.backupMgr.EndRestoreOp(false, "Fájl-visszaállítás sikertelen: "+err.Error())
|
||
return
|
||
}
|
||
msg := "Nincs hiányzó fájl — minden fájl megvan a helyén."
|
||
if n > 0 {
|
||
msg = fmt.Sprintf("%s: %d fájl visszaállítva a másodlagos másolatból.", stackName, n)
|
||
}
|
||
s.logger.Printf("[INFO] [web] Tier-2 file restore completed (async): stack=%s (%d files)", stackName, n)
|
||
s.backupMgr.EndRestoreOp(true, msg)
|
||
}()
|
||
http.Redirect(w, r, "/backups/apps?flash="+url.QueryEscape("Fájl-visszaállítás elindult — az állapot itt frissül."), http.StatusFound)
|
||
}
|
||
|
||
// settingsBaseData is the shared identity block used by every settings-family subpage
|
||
// (D1 split: /settings, /settings/notifications, /settings/security, /storage).
|
||
func (s *Server) settingsBaseData(page, title string) map[string]interface{} {
|
||
data := s.baseData(page, title)
|
||
data["CustomerID"] = s.cfg.Customer.ID
|
||
data["CustomerDomain"] = s.cfg.Customer.Domain
|
||
return data
|
||
}
|
||
|
||
// systemPageData builds the Rendszer subpage: read-only configuration, version/update,
|
||
// controller + server restart.
|
||
func (s *Server) systemPageData() map[string]interface{} {
|
||
data := s.settingsBaseData("settings", "Beállítások")
|
||
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 {
|
||
// Registry mode (v0.112.0): credential-less is a SUPPORTED mode (anonymous pull of
|
||
// the public package), not an error state — the panel says which mode is active.
|
||
data["RegistryAnonymous"] = s.updater.RegistryAnonymous()
|
||
status := s.updater.GetStatus()
|
||
data["UpdateRunning"] = status.Running
|
||
if status.LastCheck != nil {
|
||
data["UpdateAvailable"] = status.LastCheck.UpdateAvailable
|
||
data["LatestVersion"] = status.LastCheck.LatestVersion
|
||
data["LastCheckTime"] = status.LastCheck.CheckedAt
|
||
data["LastCheckError"] = status.LastCheck.Error
|
||
}
|
||
if status.LastState != nil {
|
||
data["LastUpdateState"] = status.LastState
|
||
}
|
||
data["AutoUpdateEnabled"] = s.cfg.SelfUpdate.AutoUpdate
|
||
data["AutoUpdateTime"] = s.cfg.SelfUpdate.AutoUpdateTime
|
||
// Phase 2 managed updates: the operator-enforced minimum version (FLOOR) the box auto-updates
|
||
// to. Empty = none set by the operator.
|
||
data["ControllerFloor"] = s.updater.GetFloor()
|
||
}
|
||
// Guest RAM resize card (v0.143.0, R-24): current allocation + bounds + capability/reachability.
|
||
s.memoryCardData(data)
|
||
|
||
// „Hálózat" card (R-66): where the box IS, live-computed per render and stored NOWHERE — the
|
||
// guest holds its address by DHCP, so a stored copy eventually misdirects people (S-5); an
|
||
// address-less row („—") beats a wrong address. Hálózati név renders ONLY while Megosztás is
|
||
// enabled: the NetBIOS name exists only while samba runs — showing \\FELHOM otherwise would be
|
||
// a wrong promise.
|
||
data["NetLANAddress"] = s.sambaLANAddress()
|
||
data["NetGateway"] = s.guestGateway()
|
||
if smb := s.settings.GetSMBSettings(); smb.Enabled {
|
||
data["NetSMBName"] = smb.EffectiveServerName()
|
||
}
|
||
return data
|
||
}
|
||
|
||
// storagePageData builds the Tárhely page: physical drive registry, NAS shares, and the
|
||
// data the unified agent-enriched drive view needs.
|
||
func (s *Server) storagePageData() map[string]interface{} {
|
||
data := s.settingsBaseData("storage", "Tárhely")
|
||
|
||
// Storage paths with display data
|
||
storagePaths := s.settings.GetStoragePaths()
|
||
connectedCount := 0
|
||
for _, sp := range storagePaths {
|
||
if !sp.Disconnected && !sp.Decommissioned {
|
||
connectedCount++
|
||
}
|
||
}
|
||
var storageViews []StoragePathView
|
||
for _, sp := range storagePaths {
|
||
// NAS network shares are rendered in their OWN section (NetworkStoragePaths) with the agent's
|
||
// per-share health — never in the physical-drive list (which offers eject/decommission/wipe).
|
||
if sp.IsNetwork() {
|
||
continue
|
||
}
|
||
view := StoragePathView{
|
||
StoragePath: sp,
|
||
StoppedApps: sp.StoppedStacks,
|
||
HasOtherPaths: connectedCount > 1,
|
||
IsEnrolled: strings.HasPrefix(sp.Path, "/mnt/felhom-drives/"),
|
||
}
|
||
if sp.Disconnected {
|
||
// Skip I/O calls on disconnected drives — they'd hang or fail
|
||
view.IsMounted = false
|
||
} else if sp.Decommissioned {
|
||
view.IsMounted = false
|
||
view.MigratedToLabel = s.settings.GetStorageLabel(sp.MigratedTo)
|
||
} else {
|
||
view.IsMounted = system.IsMountPoint(sp.Path)
|
||
view.AppDetails = s.appDetailsForPath(sp.Path)
|
||
view.FSInfo = system.GetFSInfo(sp.Path)
|
||
view.AppCount = len(view.AppDetails)
|
||
if di := system.GetDiskUsage(sp.Path); di != nil {
|
||
view.DiskInfo = di
|
||
}
|
||
// Detect USB for safe disconnect button
|
||
if view.FSInfo != nil && view.FSInfo.Device != "" {
|
||
view.IsUSB = system.IsUSBDevice(view.FSInfo.Device)
|
||
}
|
||
}
|
||
storageViews = append(storageViews, view)
|
||
}
|
||
data["StoragePaths"] = storageViews
|
||
return data
|
||
}
|
||
|
||
// networkStoragePageData builds the Tárhely → Hálózati tárhely (NAS) subpage: the NAS shares
|
||
// with the agent's per-share health (ok/idle/unreachable/unknown). Split from the physical
|
||
// drive page — the two storage classes were confusingly interleaved on one page.
|
||
func (s *Server) networkStoragePageData() map[string]interface{} {
|
||
data := s.settingsBaseData("storage-network", "Hálózati tárhely")
|
||
data["NetworkStoragePaths"] = s.networkStorageItems(context.Background())
|
||
// Capability banner: "no" swaps the add form for the agent-outdated notice (yes/unknown render
|
||
// the form — flaky states belong to the add-time gate). Short-budget probe, cache-backed.
|
||
data["NetAddSupport"] = s.netAddSupport()
|
||
return data
|
||
}
|
||
|
||
// notificationsPageData builds the Értesítések subpage: notification prefs + app-email.
|
||
func (s *Server) notificationsPageData() map[string]interface{} {
|
||
data := s.settingsBaseData("settings-notifications", "Értesítések")
|
||
data["HubEnabled"] = s.cfg.Hub.Enabled
|
||
data["NotificationPrefs"] = s.settings.GetNotificationPrefs()
|
||
|
||
// App-email (SMTP relay) — global toggle. Only meaningful when a hub is configured (the relay
|
||
// path runs through the hub); the template hides the control otherwise.
|
||
appEmail := s.settings.GetAppEmail()
|
||
data["AppEmailEnabled"] = appEmail.Enabled
|
||
data["AppEmailFromName"] = appEmail.FromName
|
||
data["AppEmailAvailable"] = s.cfg.Hub.URL != "" && s.cfg.MailRelay.HardEnabled()
|
||
return data
|
||
}
|
||
|
||
// securityPageData builds the Biztonság és hozzáférés subpage: password, geo-restriction,
|
||
// emergency/recovery info.
|
||
func (s *Server) securityPageData() map[string]interface{} {
|
||
data := s.settingsBaseData("settings-security", "Biztonság és hozzáférés")
|
||
|
||
// Recovery info for emergency section
|
||
data["RetrievalPassword"] = s.settings.GetRetrievalPassword()
|
||
data["HubURL"] = s.cfg.Hub.URL
|
||
data["SupportEmail"] = "support@felhom.eu"
|
||
data["SupportURL"] = "https://felhom.eu/kapcsolat"
|
||
|
||
// Geo-restriction data
|
||
data["CFConfigured"] = s.cfg.Infrastructure.CFAPIToken != ""
|
||
geo := s.settings.GetGeoRestriction()
|
||
if geo != nil {
|
||
data["GeoEnabled"] = geo.Enabled
|
||
data["GeoAllowedCountries"] = geo.AllowedCountries
|
||
data["GeoAppOverrides"] = geo.AppOverrides
|
||
data["GeoLastSync"] = geo.LastSync
|
||
data["GeoLastError"] = geo.LastSyncError
|
||
} else {
|
||
data["GeoEnabled"] = false
|
||
data["GeoAllowedCountries"] = []string{"HU"}
|
||
data["GeoAppOverrides"] = map[string]interface{}{}
|
||
}
|
||
// Deployed apps for per-app override selector
|
||
var deployedApps []map[string]string
|
||
for _, stack := range s.stackMgr.GetStacks() {
|
||
if !stack.Deployed || s.cfg.IsProtectedStack(stack.Name) {
|
||
continue
|
||
}
|
||
deployedApps = append(deployedApps, map[string]string{
|
||
"Name": stack.Name,
|
||
"Display": stack.Meta.DisplayName,
|
||
})
|
||
}
|
||
data["DeployedApps"] = deployedApps
|
||
return data
|
||
}
|
||
|
||
func (s *Server) settingsHandler(w http.ResponseWriter, r *http.Request) {
|
||
s.executeTemplate(w, r, "settings_system", s.systemPageData())
|
||
}
|
||
|
||
// storagePageHandler serves the Tárhely main-nav page (D1). Storage action flashes land here.
|
||
func (s *Server) storagePageHandler(w http.ResponseWriter, r *http.Request) {
|
||
data := s.storagePageData()
|
||
if msg := r.URL.Query().Get("storage_msg"); msg == "success" {
|
||
data["StorageSuccess"] = r.URL.Query().Get("storage_detail")
|
||
}
|
||
s.executeTemplate(w, r, "storage", data)
|
||
}
|
||
|
||
// storageNetworkPageHandler serves the Tárhely → Hálózati tárhely (NAS) subpage.
|
||
func (s *Server) storageNetworkPageHandler(w http.ResponseWriter, r *http.Request) {
|
||
s.executeTemplate(w, r, "storage_network", s.networkStoragePageData())
|
||
}
|
||
|
||
// settingsNotificationsPageHandler serves GET /settings/notifications (the POST on the same
|
||
// path is the save handler — dispatch is split in the router).
|
||
func (s *Server) settingsNotificationsPageHandler(w http.ResponseWriter, r *http.Request) {
|
||
s.executeTemplate(w, r, "settings_notifications", s.notificationsPageData())
|
||
}
|
||
|
||
// settingsSecurityPageHandler serves GET /settings/security.
|
||
func (s *Server) settingsSecurityPageHandler(w http.ResponseWriter, r *http.Request) {
|
||
s.executeTemplate(w, r, "settings_security", s.securityPageData())
|
||
}
|
||
|
||
func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request) {
|
||
_ = r.ParseForm()
|
||
currentPassword := r.FormValue("current_password")
|
||
newPassword := r.FormValue("new_password")
|
||
confirmPassword := r.FormValue("confirm_password")
|
||
|
||
if s.isDebug() {
|
||
s.logger.Printf("[DEBUG] [web] settingsPasswordHandler: password change attempt from %s", r.RemoteAddr)
|
||
}
|
||
|
||
data := s.securityPageData()
|
||
|
||
// Validate current password
|
||
effectiveHash := s.effectivePasswordHash()
|
||
if err := bcrypt.CompareHashAndPassword([]byte(effectiveHash), []byte(currentPassword)); err != nil {
|
||
if s.isDebug() {
|
||
s.logger.Printf("[DEBUG] [web] settingsPasswordHandler: current password mismatch from %s", r.RemoteAddr)
|
||
}
|
||
data["PasswordError"] = "Hibás jelenlegi jelszó"
|
||
s.executeTemplate(w, r, "settings_security", data)
|
||
return
|
||
}
|
||
|
||
// Validate new password length
|
||
if len(newPassword) < 8 {
|
||
data["PasswordError"] = "A jelszónak legalább 8 karakter hosszúnak kell lennie"
|
||
s.executeTemplate(w, r, "settings_security", data)
|
||
return
|
||
}
|
||
|
||
// Validate passwords match
|
||
if newPassword != confirmPassword {
|
||
data["PasswordError"] = "A két jelszó nem egyezik"
|
||
s.executeTemplate(w, r, "settings_security", data)
|
||
return
|
||
}
|
||
|
||
// Generate bcrypt hash
|
||
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), 10)
|
||
if err != nil {
|
||
s.logger.Printf("[ERROR] [web] Failed to hash new password: %v", err)
|
||
data["PasswordError"] = "Belső hiba a jelszó mentésekor"
|
||
s.executeTemplate(w, r, "settings_security", data)
|
||
return
|
||
}
|
||
|
||
// Save to settings.json
|
||
if err := s.settings.SetPasswordHash(string(hash)); err != nil {
|
||
s.logger.Printf("[ERROR] [web] Failed to save password to settings.json: %v", err)
|
||
data["PasswordError"] = "Belső hiba a jelszó mentésekor"
|
||
s.executeTemplate(w, r, "settings_security", data)
|
||
return
|
||
}
|
||
|
||
s.logger.Printf("[INFO] [web] Password changed via settings page from %s", r.RemoteAddr)
|
||
|
||
// Invalidate all sessions (force re-login)
|
||
s.invalidateAllSessions()
|
||
|
||
// Redirect to login with flash message
|
||
flash := url.QueryEscape("Jelszó sikeresen módosítva. Kérjük, jelentkezzen be az új jelszóval.")
|
||
http.Redirect(w, r, "/login?flash="+flash, http.StatusFound)
|
||
}
|
||
|
||
func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Request) {
|
||
_ = r.ParseForm()
|
||
|
||
if s.isDebug() {
|
||
s.logger.Printf("[DEBUG] [web] settingsNotificationsHandler: updating notification prefs from %s", r.RemoteAddr)
|
||
}
|
||
|
||
email := strings.TrimSpace(r.FormValue("notification_email"))
|
||
cooldownStr := r.FormValue("cooldown_hours")
|
||
cooldownHours := 6
|
||
if cooldownStr != "" {
|
||
if n, err := fmt.Sscanf(cooldownStr, "%d", &cooldownHours); n != 1 || err != nil {
|
||
cooldownHours = 6
|
||
}
|
||
}
|
||
if cooldownHours < 1 {
|
||
cooldownHours = 1
|
||
}
|
||
if cooldownHours > 168 {
|
||
cooldownHours = 168
|
||
}
|
||
|
||
// Collect enabled events from checkboxes
|
||
var enabledEvents []string
|
||
// Single-event checkboxes
|
||
for _, evt := range []string{
|
||
"backup_failed", "db_dump_failed", "backup_integrity_failed",
|
||
"crossdrive_failed", "offbox_enlarge_blocked", "storage_disconnected",
|
||
"node_down", "health_critical",
|
||
"storage_reconnected", "health_recovered",
|
||
} {
|
||
if r.FormValue("event_"+evt) == "on" {
|
||
enabledEvents = append(enabledEvents, evt)
|
||
}
|
||
}
|
||
// Compound toggles: one checkbox → two event types
|
||
if r.FormValue("event_disk_alerts") == "on" {
|
||
enabledEvents = append(enabledEvents, "disk_warning", "disk_critical")
|
||
}
|
||
if r.FormValue("event_expected_missed") == "on" {
|
||
enabledEvents = append(enabledEvents, "expected_backup_missed", "expected_dbdump_missed")
|
||
}
|
||
|
||
// EMPTY-EMAIL WIPE GUARD (2026-07-15 demo incident): a blank email box saved while events are
|
||
// still enabled would store an empty Email AND push it to the hub via SyncPreferences, wiping
|
||
// the customer's provisioning-seeded alert address — enabled events with nowhere to send them.
|
||
// The only way to reach this state is the bug, so refuse the save outright (BEFORE
|
||
// SetNotificationPrefs and BEFORE any hub sync), leaving the stored email untouched, and ask for
|
||
// an address. The intentional "turn everything off" case (empty email + ZERO events) falls
|
||
// through below — clearing the email is legitimate there and the empty hub push is correct.
|
||
if email == "" && len(enabledEvents) > 0 {
|
||
s.logger.Printf("[WARN] [web] Refused notification save: empty email with %d enabled event(s) — would wipe hub-side alert delivery", len(enabledEvents))
|
||
data := s.notificationsPageData()
|
||
// Repaint the customer's just-submitted intent (their ticked events + chosen cooldown, empty
|
||
// email) so they only need to add an address, not re-tick everything. Overlay the stored
|
||
// prefs — do NOT persist this; it is render-only.
|
||
data["NotificationPrefs"] = &settings.NotificationPrefs{
|
||
Email: email,
|
||
EnabledEvents: enabledEvents,
|
||
CooldownHours: cooldownHours,
|
||
}
|
||
data["NotificationError"] = "Adj meg egy értesítési e-mail címet – bekapcsolt értesítésekhez szükséges egy cím, ahova küldhetjük őket."
|
||
s.executeTemplate(w, r, "settings_notifications", data)
|
||
return
|
||
}
|
||
|
||
prefs := &settings.NotificationPrefs{
|
||
Email: email,
|
||
EnabledEvents: enabledEvents,
|
||
CooldownHours: cooldownHours,
|
||
}
|
||
|
||
if err := s.settings.SetNotificationPrefs(prefs); err != nil {
|
||
s.logger.Printf("[ERROR] [web] Failed to save notification prefs: %v", err)
|
||
data := s.notificationsPageData()
|
||
data["NotificationError"] = "Hiba a beállítások mentésekor"
|
||
s.executeTemplate(w, r, "settings_notifications", data)
|
||
return
|
||
}
|
||
|
||
s.logger.Printf("[INFO] [web] Notification preferences updated: email=%s, events=%v", email, enabledEvents)
|
||
s.reportTriggerNow() // v0.139.0: hub reflects the saved prefs in seconds, not next cycle
|
||
|
||
// Sync preferences to hub
|
||
data := s.notificationsPageData()
|
||
if s.notifier != nil && s.notifier.IsEnabled() {
|
||
if err := s.notifier.SyncPreferences(email, enabledEvents, cooldownHours); err != nil {
|
||
s.logger.Printf("[WARN] [web] Failed to sync preferences to hub: %v", err)
|
||
data["NotificationSuccess"] = fmt.Sprintf("Értesítési beállítások mentve (helyi). A központi szinkronizálás sikertelen: %v", err)
|
||
} else {
|
||
data["NotificationSuccess"] = "Értesítési beállítások mentve."
|
||
}
|
||
} else {
|
||
data["NotificationSuccess"] = "Értesítési beállítások mentve."
|
||
}
|
||
s.executeTemplate(w, r, "settings_notifications", data)
|
||
}
|
||
|
||
// settingsAppEmailHandler saves the global app-email toggle and starts/stops the on-box
|
||
// SMTP shim to match (no controller restart needed).
|
||
func (s *Server) settingsAppEmailHandler(w http.ResponseWriter, r *http.Request) {
|
||
_ = r.ParseForm()
|
||
enabled := r.FormValue("app_email_enabled") == "on" || r.FormValue("app_email_enabled") == "true"
|
||
fromName := strings.TrimSpace(r.FormValue("app_email_from_name"))
|
||
|
||
data := s.notificationsPageData()
|
||
if err := s.settings.SetAppEmail(enabled, fromName); err != nil {
|
||
s.logger.Printf("[ERROR] [web] Failed to save app-email toggle: %v", err)
|
||
data["AppEmailError"] = "Hiba az alkalmazás-email beállítás mentésekor"
|
||
s.executeTemplate(w, r, "settings_notifications", data)
|
||
return
|
||
}
|
||
// v0.139.0: the toggle is committed (the shim reconcile below is runtime state, not the
|
||
// setting) — report out-of-cycle so the hub sees it in seconds.
|
||
s.reportTriggerNow()
|
||
// Reconcile the shim's running state with the new toggle.
|
||
if s.mailShim != nil {
|
||
if err := s.mailShim.Apply(enabled); err != nil {
|
||
s.logger.Printf("[ERROR] [web] app-email shim could not be %s: %v", map[bool]string{true: "started", false: "stopped"}[enabled], err)
|
||
data = s.notificationsPageData()
|
||
data["AppEmailError"] = "A beállítás elmentve, de az email-szolgáltatás indítása nem sikerült."
|
||
s.executeTemplate(w, r, "settings_notifications", data)
|
||
return
|
||
}
|
||
}
|
||
s.logger.Printf("[INFO] [web] App-email globally %s (from_name=%q)", map[bool]string{true: "enabled", false: "disabled"}[enabled], fromName)
|
||
data = s.notificationsPageData()
|
||
if enabled {
|
||
data["AppEmailSuccess"] = "Alkalmazás-email bekapcsolva. Kapcsold be az egyes alkalmazásoknál is, ahol email-küldést szeretnél."
|
||
} else {
|
||
data["AppEmailSuccess"] = "Alkalmazás-email kikapcsolva."
|
||
}
|
||
s.executeTemplate(w, r, "settings_notifications", data)
|
||
}
|
||
|
||
func (s *Server) settingsNotificationsTestHandler(w http.ResponseWriter, r *http.Request) {
|
||
data := s.notificationsPageData()
|
||
|
||
if s.notifier == nil {
|
||
data["NotificationError"] = "Az értesítések nincsenek bekapcsolva"
|
||
s.executeTemplate(w, r, "settings_notifications", data)
|
||
return
|
||
}
|
||
|
||
err := s.notifier.SendTest()
|
||
if err != nil {
|
||
s.logger.Printf("[ERROR] [web] Test notification failed: %v", err)
|
||
data["NotificationError"] = fmt.Sprintf("Teszt email küldése sikertelen: %v", err)
|
||
s.executeTemplate(w, r, "settings_notifications", data)
|
||
return
|
||
}
|
||
|
||
data["NotificationSuccess"] = "Teszt email elküldve."
|
||
s.executeTemplate(w, r, "settings_notifications", data)
|
||
}
|
||
|
||
// --- Storage path management handlers ---
|
||
|
||
func (s *Server) countAppsUsingPath(storagePath string) int {
|
||
count := 0
|
||
for _, stack := range s.stackMgr.GetStacks() {
|
||
if !stack.Deployed {
|
||
continue
|
||
}
|
||
if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil {
|
||
if appCfg.Env["HDD_PATH"] == storagePath {
|
||
count++
|
||
}
|
||
}
|
||
}
|
||
return count
|
||
}
|
||
|
||
func (s *Server) appsUsingPath(storagePath string) []string {
|
||
return appsUsingPathIn(s.stackMgr.GetStacks(), s.stackMgr.LoadAppConfigByName, storagePath)
|
||
}
|
||
|
||
// missingStorageLabel reports whether a deployed app's HDD_PATH resolves to an UNAVAILABLE registry
|
||
// path (decommissioned, disconnected, or no longer registered) and returns its human label. An app
|
||
// with no HDD_PATH (SSD-resident) is never "missing".
|
||
func (s *Server) missingStorageLabel(hddPath string) (string, bool) {
|
||
if hddPath == "" {
|
||
return "", false
|
||
}
|
||
for _, sp := range s.settings.GetStoragePaths() {
|
||
if sp.Path == hddPath {
|
||
// A NAS network path is NEVER "missing": its availability is the agent's per-share liveness
|
||
// (surfaced as a recoverable warning by networkStorageWarnings), NOT the drive missing/stop
|
||
// cascade. An `unreachable` NAS must not look like a removed drive.
|
||
if sp.IsNetwork() {
|
||
return "", false
|
||
}
|
||
if sp.Decommissioned || sp.Disconnected {
|
||
return s.settings.GetStorageLabel(hddPath), true
|
||
}
|
||
return "", false // present + available
|
||
}
|
||
}
|
||
return s.settings.GetStorageLabel(hddPath), true // not in registry → its drive is gone
|
||
}
|
||
|
||
// networkStorageWarnings returns two stack-name → share-label maps for deployed apps on NAS
|
||
// network paths:
|
||
// - warnings: the agent reports the share `unreachable` — RECOVERABLE ("hálózati tárhely nem
|
||
// elérhető"), explicitly NOT the drive missing/stop-cascade; clears when the NAS returns.
|
||
// - stubs: the path is a plain local STUB in the controller's namespace (RCA fix 2 — the
|
||
// guest-reboot state where apps silently see an empty dir while the agent's host-side view is
|
||
// healthy). Checked from THIS process (the consuming namespace), independent of the agent.
|
||
//
|
||
// Stub wins: a stack never appears in both. An idle autofs trigger is HEALTHY and is never
|
||
// force-mounted from here (classification reads the fs magic only). Best-effort: an agent error
|
||
// drops the unreachable leg but the stub leg still runs.
|
||
func (s *Server) networkStorageWarnings(list []stacks.Stack) (warnings, stubs map[string]string) {
|
||
warnings, stubs = map[string]string{}, map[string]string{}
|
||
if s.settings == nil || s.stackMgr == nil {
|
||
return warnings, stubs
|
||
}
|
||
netPaths := map[string]settings.StoragePath{}
|
||
for _, sp := range s.settings.GetStoragePaths() {
|
||
if sp.IsNetwork() {
|
||
netPaths[sp.Path] = sp
|
||
}
|
||
}
|
||
if len(netPaths) == 0 {
|
||
return warnings, stubs
|
||
}
|
||
// Stub leg — the consuming namespace's own verdict (no agent, no force-mount).
|
||
stubPaths := s.stubNetworkPaths(netPaths)
|
||
// Unreachable leg — the agent's host-side liveness view (unchanged behavior).
|
||
unreachable := map[string]string{} // path → label
|
||
if agent, err := s.agentClient(); err == nil {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
if mounts, err := agent.ListNetStorage(ctx); err == nil {
|
||
for _, m := range mounts {
|
||
if !m.Unreachable() { // only `unreachable` is degraded; `idle`/`ok` are benign
|
||
continue
|
||
}
|
||
p := settings.NetworkMountRoot + "/" + m.Name
|
||
if sp, ok := netPaths[p]; ok {
|
||
lbl := sp.Label
|
||
if lbl == "" {
|
||
lbl = m.Name
|
||
}
|
||
unreachable[p] = lbl
|
||
}
|
||
}
|
||
} else {
|
||
s.logger.Printf("[WARN] [web] network storage health unavailable for warnings: %v", err)
|
||
}
|
||
}
|
||
return networkStorageWarningsIn(list, s.stackMgr.LoadAppConfigByName, unreachable, stubPaths)
|
||
}
|
||
|
||
// stubNetworkPaths classifies each registered network path in the controller's namespace and
|
||
// returns path→label for every STUB (RCA fix 2). Idle autofs is healthy and classification never
|
||
// force-mounts (fs magic read only); unknown (timeout/statfs error) is NOT a stub — fail open.
|
||
func (s *Server) stubNetworkPaths(netPaths map[string]settings.StoragePath) map[string]string {
|
||
out := map[string]string{}
|
||
for p, sp := range netPaths {
|
||
if s.classifyFSPath(p) == system.FSClassStub {
|
||
lbl := sp.Label
|
||
if lbl == "" {
|
||
lbl = strings.TrimPrefix(p, settings.NetworkMountRoot+"/")
|
||
}
|
||
out[p] = lbl
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// networkStorageWarningsIn is the pure per-stack mapping core (the appsUsingPathIn pattern): given
|
||
// the path→label verdict sets, assign each deployed stack its badge. Stub WINS over unreachable —
|
||
// a stack never appears in both maps.
|
||
func networkStorageWarningsIn(list []stacks.Stack, load func(string) *stacks.AppConfig, unreachable, stubPaths map[string]string) (warnings, stubs map[string]string) {
|
||
warnings, stubs = map[string]string{}, map[string]string{}
|
||
if len(unreachable) == 0 && len(stubPaths) == 0 {
|
||
return warnings, stubs
|
||
}
|
||
for _, st := range list {
|
||
if !st.Deployed {
|
||
continue
|
||
}
|
||
if cfg := load(st.Name); cfg != nil {
|
||
hdd := cfg.Env["HDD_PATH"]
|
||
if lbl, bad := stubPaths[hdd]; bad {
|
||
stubs[st.Name] = lbl // stub wins over unreachable
|
||
continue
|
||
}
|
||
if lbl, bad := unreachable[hdd]; bad {
|
||
warnings[st.Name] = lbl
|
||
}
|
||
}
|
||
}
|
||
return warnings, stubs
|
||
}
|
||
|
||
// missingStorageMap returns stack-name → storage label for every deployed app whose data drive is
|
||
// currently unavailable (drives the "Hiányzó tárhely" dashboard/stacks/app-card indicator).
|
||
func (s *Server) missingStorageMap(list []stacks.Stack) map[string]string {
|
||
out := map[string]string{}
|
||
for _, st := range list {
|
||
if !st.Deployed {
|
||
continue
|
||
}
|
||
if cfg := s.stackMgr.LoadAppConfigByName(st.Name); cfg != nil {
|
||
if label, missing := s.missingStorageLabel(cfg.Env["HDD_PATH"]); missing {
|
||
out[st.Name] = label
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// appsUsingPathIn is the pure core of appsUsingPath (testable without a live stacks.Manager): the
|
||
// deployed apps whose data dir (app.yaml HDD_PATH) is exactly storagePath, by display name. This is
|
||
// the "name the apps that break" list for the type-to-confirm wipe/eject UI.
|
||
func appsUsingPathIn(allStacks []stacks.Stack, loadCfg func(string) *stacks.AppConfig, storagePath string) []string {
|
||
var names []string
|
||
for _, stack := range allStacks {
|
||
if !stack.Deployed {
|
||
continue
|
||
}
|
||
if appCfg := loadCfg(stack.Name); appCfg != nil {
|
||
if appCfg.Env["HDD_PATH"] == storagePath {
|
||
names = append(names, stack.Meta.DisplayName)
|
||
}
|
||
}
|
||
}
|
||
return names
|
||
}
|
||
|
||
func (s *Server) appDetailsForPath(storagePath string) []StorageAppDetail {
|
||
var details []StorageAppDetail
|
||
for _, stack := range s.stackMgr.GetStacks() {
|
||
if !stack.Deployed {
|
||
continue
|
||
}
|
||
appCfg := s.stackMgr.LoadAppConfigByName(stack.Name)
|
||
if appCfg == nil {
|
||
continue
|
||
}
|
||
hddPath := appCfg.Env["HDD_PATH"]
|
||
if hddPath != storagePath {
|
||
continue
|
||
}
|
||
detail := StorageAppDetail{
|
||
Name: stack.Meta.DisplayName,
|
||
Stack: stack.Meta.Slug,
|
||
}
|
||
// Try to get data size from the storage subdirectory. F-S2: the app's real appdata dir name is
|
||
// NOT always the stack name (paperless-ngx writes appdata/paperless) — sum the resolved dir(s).
|
||
// Here hddPath == storagePath (the drive's in-guest mount is the namespace root, Model A).
|
||
var total int64
|
||
var any bool
|
||
for _, name := range s.stackMgr.ResolveAppDataDirNames(stack.Name) {
|
||
d := backup.AppDataDir(storagePath, name)
|
||
if fi, err := os.Stat(d); err == nil && fi.IsDir() {
|
||
total += dirSizeBytesWalk(d)
|
||
any = true
|
||
}
|
||
}
|
||
if any {
|
||
detail.SizeHuman = humanizeDirBytes(total)
|
||
}
|
||
details = append(details, detail)
|
||
}
|
||
return details
|
||
}
|
||
|
||
// dirSizeBytesWalk returns the total size in bytes of the regular files under path (0 if absent).
|
||
func dirSizeBytesWalk(path string) int64 {
|
||
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
|
||
})
|
||
return total
|
||
}
|
||
|
||
// humanizeDirBytes formats a byte count as B/KB/MB/GB (the storage-page display convention).
|
||
func humanizeDirBytes(total int64) string {
|
||
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)
|
||
}
|
||
}
|
||
|
||
// dirSizeHuman returns a human-readable size for a single directory.
|
||
func dirSizeHuman(path string) string {
|
||
return humanizeDirBytes(dirSizeBytesWalk(path))
|
||
}
|
||
|
||
func formatFreeSpace(gb float64) string {
|
||
if gb >= 1000 {
|
||
return fmt.Sprintf("%.1f TB", gb/1024)
|
||
}
|
||
return fmt.Sprintf("%.1f GB", gb)
|
||
}
|
||
|
||
func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Request) {
|
||
_ = r.ParseForm()
|
||
|
||
path := filepath.Clean(r.FormValue("storage_path"))
|
||
label := strings.TrimSpace(r.FormValue("storage_label"))
|
||
isDefault := r.FormValue("storage_default") == "true"
|
||
|
||
if s.isDebug() {
|
||
s.logger.Printf("[DEBUG] [web] settingsStorageAddHandler: path=%s label=%q default=%v from %s", path, label, isDefault, r.RemoteAddr)
|
||
}
|
||
|
||
if label == "" {
|
||
label = settings.InferStorageLabel(path)
|
||
}
|
||
|
||
data := s.storagePageData()
|
||
|
||
// 1. Exists and is directory
|
||
fi, err := os.Stat(path)
|
||
if err != nil || !fi.IsDir() {
|
||
data["StorageError"] = "Az útvonal nem létezik vagy nem mappa."
|
||
s.executeTemplate(w, r, "storage", data)
|
||
return
|
||
}
|
||
|
||
// 2. Is mount point
|
||
if !system.IsMountPoint(path) {
|
||
data["StorageError"] = "Ez az útvonal nem külön csatlakoztatott meghajtó. Adatok az SSD-re kerülnének!"
|
||
s.executeTemplate(w, r, "storage", data)
|
||
return
|
||
}
|
||
|
||
// 3. Writable
|
||
if !system.IsWritable(path) {
|
||
data["StorageError"] = "Az útvonal nem írható."
|
||
s.executeTemplate(w, r, "storage", data)
|
||
return
|
||
}
|
||
|
||
// 4. No overlap with existing paths
|
||
for _, existing := range s.settings.GetStoragePaths() {
|
||
if system.PathsOverlap(path, existing.Path) {
|
||
data["StorageError"] = fmt.Sprintf("Az útvonal átfedi a már regisztrált %s útvonalat.", existing.Path)
|
||
s.executeTemplate(w, r, "storage", data)
|
||
return
|
||
}
|
||
}
|
||
|
||
// 5. Soft warning if not under /mnt/
|
||
if !strings.HasPrefix(path, "/mnt/") {
|
||
s.logger.Printf("[WARN] [web] Storage path %s is not under /mnt/ — unusual but allowed", path)
|
||
}
|
||
|
||
sp := settings.StoragePath{
|
||
Path: path,
|
||
Label: label,
|
||
IsDefault: isDefault,
|
||
Schedulable: true,
|
||
AddedAt: time.Now().UTC().Format(time.RFC3339),
|
||
}
|
||
|
||
if err := s.settings.AddStoragePath(sp); err != nil {
|
||
s.logger.Printf("[ERROR] [web] Failed to add storage path: %v", err)
|
||
data["StorageError"] = "Hiba a mentés során."
|
||
s.executeTemplate(w, r, "storage", data)
|
||
return
|
||
}
|
||
|
||
s.logger.Printf("[INFO] [web] Storage path added: %s (%s)", path, label)
|
||
go s.SyncFileBrowserMounts()
|
||
http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló sikeresen hozzáadva: "+path), http.StatusFound)
|
||
}
|
||
|
||
func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Request) {
|
||
_ = r.ParseForm()
|
||
path := r.FormValue("storage_path")
|
||
|
||
if s.isDebug() {
|
||
s.logger.Printf("[DEBUG] [web] settingsStorageRemoveHandler: path=%s from %s", path, r.RemoteAddr)
|
||
}
|
||
|
||
data := s.storagePageData()
|
||
|
||
// Check: apps using this path
|
||
apps := s.appsUsingPath(path)
|
||
if len(apps) > 0 {
|
||
data["StorageError"] = fmt.Sprintf("Nem törölhető: az alábbi alkalmazások használják: %s", strings.Join(apps, ", "))
|
||
s.executeTemplate(w, r, "storage", data)
|
||
return
|
||
}
|
||
|
||
// Check: cannot remove default
|
||
for _, sp := range s.settings.GetStoragePaths() {
|
||
if sp.Path == path && sp.IsDefault {
|
||
data["StorageError"] = "Az alapértelmezett adattároló nem törölhető."
|
||
s.executeTemplate(w, r, "storage", data)
|
||
return
|
||
}
|
||
}
|
||
|
||
// Check: last path
|
||
if len(s.settings.GetStoragePaths()) <= 1 {
|
||
data["StorageError"] = "Az utolsó adattároló nem törölhető."
|
||
s.executeTemplate(w, r, "storage", data)
|
||
return
|
||
}
|
||
|
||
if err := s.settings.RemoveStoragePath(path); err != nil {
|
||
data["StorageError"] = "Hiba a törlés során."
|
||
s.executeTemplate(w, r, "storage", data)
|
||
return
|
||
}
|
||
|
||
s.logger.Printf("[INFO] [web] Storage path removed: %s", path)
|
||
// Sync FileBrowser mounts after storage path removal
|
||
go s.SyncFileBrowserMounts()
|
||
http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló eltávolítva: "+path), http.StatusFound)
|
||
}
|
||
|
||
func (s *Server) settingsStorageDefaultHandler(w http.ResponseWriter, r *http.Request) {
|
||
_ = r.ParseForm()
|
||
path := r.FormValue("storage_path")
|
||
|
||
if s.isDebug() {
|
||
s.logger.Printf("[DEBUG] [web] settingsStorageDefaultHandler: path=%s from %s", path, r.RemoteAddr)
|
||
}
|
||
|
||
if err := s.settings.SetDefaultStoragePath(path); err != nil {
|
||
s.logger.Printf("[ERROR] [web] Failed to set default storage path: %v", err)
|
||
http.Redirect(w, r, "/storage", http.StatusFound)
|
||
return
|
||
}
|
||
s.logger.Printf("[INFO] [web] Default storage path set to %s", path)
|
||
http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape("Alapértelmezett adattároló beállítva: "+path), http.StatusFound)
|
||
}
|
||
|
||
func (s *Server) settingsStorageSchedulableHandler(w http.ResponseWriter, r *http.Request) {
|
||
_ = r.ParseForm()
|
||
path := r.FormValue("storage_path")
|
||
schedulable := r.FormValue("schedulable") == "true"
|
||
|
||
if s.isDebug() {
|
||
s.logger.Printf("[DEBUG] [web] settingsStorageSchedulableHandler: path=%s schedulable=%v from %s", path, schedulable, r.RemoteAddr)
|
||
}
|
||
|
||
if err := s.settings.SetSchedulable(path, schedulable); err != nil {
|
||
s.logger.Printf("[ERROR] [web] Failed to update schedulable: %v", err)
|
||
http.Redirect(w, r, "/storage", http.StatusFound)
|
||
return
|
||
}
|
||
s.logger.Printf("[INFO] [web] Storage schedulable updated: %s → %v", path, schedulable)
|
||
http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló állapot módosítva: "+path), http.StatusFound)
|
||
}
|
||
|
||
func (s *Server) settingsStorageLabelHandler(w http.ResponseWriter, r *http.Request) {
|
||
_ = r.ParseForm()
|
||
path := r.FormValue("storage_path")
|
||
label := strings.TrimSpace(r.FormValue("storage_label"))
|
||
|
||
if s.isDebug() {
|
||
s.logger.Printf("[DEBUG] [web] settingsStorageLabelHandler: path=%s label=%q from %s", path, label, r.RemoteAddr)
|
||
}
|
||
|
||
if label == "" || len(label) > 50 {
|
||
data := s.storagePageData()
|
||
data["StorageError"] = "A megnevezés nem lehet üres és legfeljebb 50 karakter."
|
||
s.executeTemplate(w, r, "storage", data)
|
||
return
|
||
}
|
||
|
||
if err := s.settings.SetStorageLabel(path, label); err != nil {
|
||
s.logger.Printf("[ERROR] [web] Failed to set storage label: %v", err)
|
||
data := s.storagePageData()
|
||
data["StorageError"] = "Hiba a megnevezés mentésekor."
|
||
s.executeTemplate(w, r, "storage", data)
|
||
return
|
||
}
|
||
|
||
s.logger.Printf("[INFO] [web] Storage label updated: %s → %q", path, label)
|
||
http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape("Megnevezés módosítva: "+label), http.StatusFound)
|
||
}
|
||
|
||
// SyncFileBrowserMounts regenerates FileBrowser's docker-compose.yml and config.yaml
|
||
// with volume mounts and sources for all registered storage paths, then recreates the container.
|
||
func (s *Server) SyncFileBrowserMounts() {
|
||
s.syncFileBrowserMounts(false)
|
||
}
|
||
|
||
// SyncFileBrowserMountsReset is like SyncFileBrowserMounts but resets the FileBrowser
|
||
// database when sources change. Use only after restore — normal operations should use
|
||
// SyncFileBrowserMounts to preserve user accounts, permissions, and share links.
|
||
func (s *Server) SyncFileBrowserMountsReset() {
|
||
s.syncFileBrowserMounts(true)
|
||
}
|
||
|
||
// skipFileBrowserPath reports whether a registered storage path should be skipped this FileBrowser
|
||
// sync pass: an EXTERNAL drive path (under StableParentDir) that is not currently a live mountpoint is
|
||
// detached, so its userdata skeleton must not be created (would land on the rootfs) and it must not be
|
||
// mounted into FileBrowser until it returns. System/local paths (not under StableParentDir) are never
|
||
// skipped. Pure + isMount-injected for testability.
|
||
func skipFileBrowserPath(path string, isMount func(string) bool) bool {
|
||
return strings.HasPrefix(path, StableParentDir+"/") && !isMount(path)
|
||
}
|
||
|
||
func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
|
||
// Prevent concurrent syncs — multiple callers can race on the same files (H5 fix).
|
||
s.fileBrowserMu.Lock()
|
||
defer s.fileBrowserMu.Unlock()
|
||
|
||
stackDir := "/opt/docker/stacks/filebrowser"
|
||
composePath := stackDir + "/docker-compose.yml"
|
||
|
||
// Check if FileBrowser stack exists
|
||
if _, err := os.Stat(composePath); os.IsNotExist(err) {
|
||
s.logger.Printf("[WARN] [web] FileBrowser stack not found at %s — skipping mount sync", composePath)
|
||
return
|
||
}
|
||
|
||
// Get all active storage paths
|
||
paths := s.settings.GetStoragePaths()
|
||
|
||
// Use domain from controller config
|
||
domain := s.cfg.Customer.Domain
|
||
if domain == "" {
|
||
s.logger.Printf("[WARN] [web] Cannot sync FileBrowser mounts — customer domain not configured")
|
||
return
|
||
}
|
||
|
||
// Build volume mount lines + the config source set (R-67: the two must agree — a source with
|
||
// no mount behind it renders a broken sidebar entry).
|
||
storageMounts, configPaths := buildFileBrowserPaths(paths, fbPathDeps{
|
||
isMount: system.IsMountPoint,
|
||
classify: s.classifyFSPath,
|
||
ensureSkeleton: appbackup.EnsureUserdataSkeleton,
|
||
logger: s.logger,
|
||
})
|
||
|
||
// Generate and write config.yaml (sources + sidebar entries per drive/share)
|
||
configPath := stackDir + "/config.yaml"
|
||
fbConfig := generateFileBrowserConfig(configPaths)
|
||
|
||
// Capture the current on-disk content BEFORE any writes, so we can detect whether this sync
|
||
// actually changes anything (F2). The integrations' ReapplyConfigForTarget edits config.yaml
|
||
// after we write it, so the recreate decision is made AFTER the writes against the final files.
|
||
oldConfig, _ := os.ReadFile(configPath)
|
||
oldCompose, _ := os.ReadFile(composePath)
|
||
|
||
// Detect if sources changed — if so, the database must be reset so
|
||
// FileBrowser picks up the new source list (user prefs cache old sources).
|
||
sourcesChanged := string(oldConfig) != fbConfig
|
||
|
||
if err := os.WriteFile(configPath, []byte(fbConfig), 0644); err != nil {
|
||
s.logger.Printf("[ERROR] [web] Failed to write FileBrowser config: %v", err)
|
||
return
|
||
}
|
||
|
||
// Re-apply active integrations into config.yaml (before container restart)
|
||
if im := s.integrationMgr.Load(); im != nil {
|
||
im.ReapplyConfigForTarget("filebrowser")
|
||
}
|
||
|
||
// Generate and write compose (includes config.yaml mount)
|
||
compose := generateFileBrowserCompose(domain, storageMounts)
|
||
if err := os.WriteFile(composePath, []byte(compose), 0644); err != nil {
|
||
s.logger.Printf("[ERROR] [web] Failed to write FileBrowser compose: %v", err)
|
||
return
|
||
}
|
||
|
||
// Read back the FINAL content (post-integrations) to decide whether a recreate is warranted (F2):
|
||
// a controller restart or a no-op storage sync must NOT bounce the customer's file UI when nothing
|
||
// actually changed. The recreate only happens when config.yaml or the compose file truly differ.
|
||
finalConfig, _ := os.ReadFile(configPath)
|
||
finalCompose, _ := os.ReadFile(composePath)
|
||
changed := fbNeedsRecreate(oldConfig, finalConfig, oldCompose, finalCompose)
|
||
|
||
// If sources changed and caller requested a DB reset (restore flow),
|
||
// nuke the data volume so FileBrowser re-reads config.yaml from scratch.
|
||
// Normal operations skip this to preserve user accounts, permissions, and share links.
|
||
if sourcesChanged && resetDBOnChange {
|
||
s.logger.Printf("[INFO] [web] FileBrowser sources changed — resetting database (restore mode)")
|
||
resetCtx, resetCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer resetCancel()
|
||
stop := exec.CommandContext(resetCtx, "docker", "compose", "down", "-v")
|
||
stop.Dir = stackDir
|
||
if out, err := stop.CombinedOutput(); err != nil {
|
||
s.logger.Printf("[WARN] [web] FileBrowser down -v: %s — %v", strings.TrimSpace(string(out)), err)
|
||
}
|
||
changed = true // a DB reset removed the container — it must be recreated
|
||
}
|
||
|
||
// Bring FileBrowser up. H16: 60s timeout to prevent hanging indefinitely. Only force-recreate when
|
||
// something actually changed; otherwise a plain `up -d` just ensures it's running without a bounce.
|
||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||
defer cancel()
|
||
args := []string{"compose", "up", "-d", "--remove-orphans"}
|
||
if changed {
|
||
args = []string{"compose", "up", "-d", "--force-recreate", "--remove-orphans"}
|
||
}
|
||
cmd := exec.CommandContext(ctx, "docker", args...)
|
||
cmd.Dir = stackDir
|
||
if out, err := cmd.CombinedOutput(); err != nil {
|
||
s.logger.Printf("[ERROR] [web] Failed to bring up FileBrowser: %s — %v", string(out), err)
|
||
} else if changed {
|
||
s.logger.Printf("[INFO] [web] FileBrowser mounts synced (recreated) — %d storage path(s), config updated", len(paths))
|
||
} else {
|
||
s.logger.Printf("[INFO] [web] FileBrowser sync — no config/compose change, ensured running without recreate (%d storage path(s))", len(paths))
|
||
}
|
||
}
|
||
|
||
// fbPathDeps are the injectable edges of buildFileBrowserPaths — everything that would otherwise
|
||
// touch the real mount table, the real filesystem or a real statfs, so the A/B/C/D scenarios run
|
||
// without a drive, a NAS or docker.
|
||
type fbPathDeps struct {
|
||
isMount func(string) bool // drive-absent gate probe (production: system.IsMountPoint)
|
||
classify func(string) string // network stub gate (production: Server.classifyFSPath; nil → include, fail open)
|
||
ensureSkeleton func(string) error // userdata skeleton (production: appbackup.EnsureUserdataSkeleton) — DRIVES ONLY
|
||
logger *log.Logger
|
||
}
|
||
|
||
// buildFileBrowserPaths computes one FileBrowser sync pass's volume mount lines + the source-list
|
||
// paths, with the per-kind gates applied. Two storage classes, two DIFFERENT gates:
|
||
//
|
||
// DRIVES (v0.66.0 semantics, unchanged byte-for-byte): scope to the `userdata/` subtree, pre-create
|
||
// the skeleton, and apply the drive-absent gate — a detached external drive must not get a skeleton
|
||
// written onto the rootfs. Drives always stay in the config source list (pre-R-67 behavior kept).
|
||
//
|
||
// NETWORK SHARES (R-67): the drive-absent gate does NOT apply — an idle automount trigger is
|
||
// HEALTHY (first access mounts it; the old gate skipped an idle share forever). The gate here is
|
||
// the STUB classifier instead, and it is a data-safety gate, not cosmetics: exposing a local stub
|
||
// dir lets a customer upload into a directory the real mount will later SHADOW — their files
|
||
// silently vanish from view. A stub share is excluded from BOTH the mounts and the source list
|
||
// this pass (a source without a mount is a broken sidebar entry). autofs-trigger / real network fs
|
||
// / unknown all include (unknown fails open — a wedged NAS must not hide the share forever).
|
||
// The bind is the share ROOT with :rslave — load-bearing: host-side automount wake and
|
||
// idle-unmount events must propagate into the RUNNING container (Phase-0 probe 2026-07-22 proved
|
||
// an in-container access through an rslave bind wakes the idle trigger). Never a skeleton, never
|
||
// any write toward the NAS — Felhom conventions must not be written onto a customer's own NAS.
|
||
func buildFileBrowserPaths(paths []settings.StoragePath, d fbPathDeps) (storageMounts []string, configPaths []settings.StoragePath) {
|
||
configPaths = make([]settings.StoragePath, 0, len(paths))
|
||
for _, sp := range paths {
|
||
mountName := filepath.Base(sp.Path) // "/mnt/hdd_1" → "hdd_1"; ".../Felhom-Share" → "Felhom-Share"
|
||
if sp.IsNetwork() {
|
||
if d.classify != nil && d.classify(sp.Path) == system.FSClassStub {
|
||
if d.logger != nil {
|
||
d.logger.Printf("[WARN] [web] FileBrowser: %s namespace sees a local stub, not the NAS — excluded until propagation recovers", mountName)
|
||
}
|
||
continue
|
||
}
|
||
storageMounts = append(storageMounts, fmt.Sprintf(" - %s:/srv/%s:rslave", sp.Path, mountName))
|
||
configPaths = append(configPaths, sp)
|
||
continue
|
||
}
|
||
// Drives are ALWAYS in the source list (pre-R-67 behavior: the config listed every
|
||
// registered path; only the mount obeys the drive-absent gate).
|
||
configPaths = append(configPaths, sp)
|
||
// Drive-absent gate: an external drive path that isn't currently a live mountpoint is detached —
|
||
// don't create its userdata skeleton (would write onto the rootfs) and don't mount it into
|
||
// FileBrowser this pass. It returns on the next sync after reconnect. Matches planDriveGates'
|
||
// external-only rule (system paths, not under StableParentDir, are never skipped).
|
||
if skipFileBrowserPath(sp.Path, d.isMount) {
|
||
if d.logger != nil {
|
||
d.logger.Printf("[INFO] [web] FileBrowser: drive %s not mounted — skipping userdata skeleton", sp.Path)
|
||
}
|
||
continue
|
||
}
|
||
// SCOPE to the drive's `userdata/` subtree (v0.66.0): the customer browses ONLY userdata —
|
||
// app internals (appdata/) and the recovery units + Tier 2 copies (backups/) are NOT mounted
|
||
// into FileBrowser. userdata is owned group 1000 mode 2775 (setgid), and FileBrowser runs as
|
||
// uid 1000 → it can create folders + upload files (the old appdata mount was guest-root 0755
|
||
// → permission-denied). Pre-create the full skeleton with the convention.
|
||
if err := d.ensureSkeleton(sp.Path); err != nil {
|
||
if d.logger != nil {
|
||
d.logger.Printf("[WARN] [web] FileBrowser: could not ensure userdata skeleton on %s: %v", sp.Path, err)
|
||
}
|
||
}
|
||
userdataSrc := appbackup.UserdataDir(sp.Path)
|
||
storageMounts = append(storageMounts, fmt.Sprintf(" - %s:/srv/%s", userdataSrc, mountName))
|
||
}
|
||
return storageMounts, configPaths
|
||
}
|
||
|
||
// fbNeedsRecreate reports whether the FileBrowser container must be force-recreated: true when either
|
||
// the config.yaml or the compose file content changed between the pre-sync and post-sync state. On the
|
||
// first-ever run the old files are empty → differs from the freshly generated content → true (creates
|
||
// it). Pure, so syncFileBrowserMounts' recreate decision is unit-testable without shelling to docker.
|
||
func fbNeedsRecreate(oldConfig, newConfig, oldCompose, newCompose []byte) bool {
|
||
return !bytes.Equal(oldConfig, newConfig) || !bytes.Equal(oldCompose, newCompose)
|
||
}
|
||
|
||
// generateFileBrowserCompose returns a FileBrowser docker-compose.yml string with the given domain
|
||
// and storage volume-mount lines. Delegates to internal/infra (the single source of truth — so the
|
||
// pinned image and the base-infra bring-up path can never diverge).
|
||
func generateFileBrowserCompose(domain string, storageMounts []string) string {
|
||
return infra.RenderFileBrowserCompose(domain, storageMounts)
|
||
}
|
||
|
||
// generateFileBrowserConfig returns a FileBrowser Quantum config.yaml with a separate source per
|
||
// registered storage path. Delegates to internal/infra (single source of truth).
|
||
func generateFileBrowserConfig(paths []settings.StoragePath) string {
|
||
return infra.RenderFileBrowserConfig(paths)
|
||
}
|