package web import ( "fmt" "path/filepath" "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" "gitea.dooplex.hu/admin/felhom-controller/internal/system" ) // „Hova tegyem a fájlokat?" — the app-page folder card (R-75). // // Rendered only for DEPLOYED apps that declare data_paths (Fork-2: all catalog apps get the // skeleton, but only deployed apps get a UI affordance — an empty folder for an app nobody installed // is the thing that would actually confuse someone). // DataPathCard is one folder row on the app page. type DataPathCard struct { Label string // the catalog's Hungarian label Link string // FileBrowser deep link Consequence string // class-DRIVEN copy (never hand-written per app) IsImport bool // drives the free-space line FreeSpace string // system-drive headroom, import rows only ("" when unreadable) } // consequenceFor maps a folder's DERIVED BACKUP CLASS to the sentence the customer reads (Fork-4). // // It is driven by the class, not by the role and not by a per-app string, so the promise the UI makes // can never drift from what the backup engines actually do. `excluded` means the tier filter drops it // at EVERY tier — so a drop-zone must say, in the customer's own language, that the folder is // temporary and unbacked. Saying anything softer would be a false promise about their files. func consequenceFor(class appbackup.BindClass, role stacks.DataPathRole) string { switch class { case appbackup.ClassExcluded: if role == stacks.RoleImport { return "Ide másold a feldolgozandó fájlokat. Az alkalmazás beolvassa, majd törli innen — " + "ez a mappa átmeneti, és nem készül róla biztonsági mentés." } return "Ez a mappa átmeneti, és nem készül róla biztonsági mentés." case appbackup.ClassMandatory, appbackup.ClassOptional: return "Itt tárolódnak a fájljaid. Biztonsági mentés készül róla." default: // No classification (legacy app, or a bind with no backup block). Say nothing rather than // guess — an unverified backup promise is worse than no sentence at all. return "" } } // buildDataPathCards turns an app's validated data_paths into rendered rows. // // classOf resolves a (root, relpath) to its backup class; missing ⇒ empty class ⇒ no consequence // line. A row whose deep link cannot be built (no domain) is dropped rather than rendered dead. func (s *Server) buildDataPathCards(st *stacks.Stack) []DataPathCard { if st == nil || !st.Deployed || len(st.Meta.DataPaths) == 0 { return nil } domain := s.cfg.Customer.Domain if domain == "" { return nil } classOf := map[string]appbackup.BindClass{} if binds, has := s.stackMgr.ClassifiedBinds(st.Name); has { for _, b := range binds { classOf[string(b.Root)+"\x00"+b.RelPath] = b.Class } } var freeSpace string if s.stackMgr != nil { if root := s.stackMgr.GetImportRoot(); root != "" { if du := system.GetDiskUsage(root); du != nil { freeSpace = fmt.Sprintf("%.1f GB szabad", du.AvailGB) } } } cards := make([]DataPathCard, 0, len(st.Meta.DataPaths)) for _, dp := range st.Meta.DataPaths { var link string switch dp.Root { case appbackup.RootImport: link = importFolderLink(domain, dp.Path) case appbackup.RootUserdata: // A userdata folder lives on the app's OWN drive, so its FileBrowser source is that // drive's sidebar entry. Resolve it from the app's HDD_PATH; skip the row if we cannot. src := s.fbSourceNameForApp(st.Name) if src == "" { continue } link = fileBrowserLink(domain, src, dp.Path) default: // hdd: app-internal (appdata/) — NOT customer-browsable, FileBrowser does not mount it. // Surfacing a link here would 404. Skipped deliberately; the catalog should not annotate // an hdd path with a customer-facing role. continue } isImport := dp.Role == stacks.RoleImport card := DataPathCard{ Label: dp.Label, Link: link, Consequence: consequenceFor(classOf[string(dp.Root)+"\x00"+dp.Path], dp.Role), IsImport: isImport, } if isImport { // The system SSD filling is a different severity from a data drive filling — it can take // the whole guest down — and the customer has no other signal that the drop-zone is not // bottomless. card.FreeSpace = freeSpace } cards = append(cards, card) } return cards } // fbSourceNameForApp returns the FileBrowser sidebar source name for the drive an app is deployed // on: the storage path's label when it has one, else the mount basename — exactly what // RenderFileBrowserConfig emits, so the deep link and the sidebar can never disagree. func (s *Server) fbSourceNameForApp(stackName string) string { appCfg := s.stackMgr.LoadAppConfigByName(stackName) if appCfg == nil { return "" } hdd := appCfg.Env["HDD_PATH"] if hdd == "" { return "" } for _, sp := range s.settings.GetStoragePaths() { if sp.Path != hdd || sp.Decommissioned { continue } if sp.Label != "" { return sp.Label } return filepath.Base(sp.Path) } return "" }