Files
felhom-controller/controller/internal/web/datapath_card.go
T

299 lines
12 KiB
Go

package web
import (
"path"
"path/filepath"
"sort"
"strings"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"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 or media_library (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; "" = the folder's source is not on the box now — shown as text
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)
Role stacks.DataPathRole
IsMedia bool // a per-drive media folder (media_library), not a declared data_path
}
// 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.
// R-589/R-590 (v0.258.0): `msg` renders the sentence in the HOUSEHOLD'S language. It is a
// parameter rather than a package-level lookup because this sentence is a PROMISE ABOUT THE
// CUSTOMER'S FILES — an English household reading „nem készül róla biztonsági mentés" as decoration
// may leave originals in a drop-zone the backup filter discards at every tier. Found live on
// 2026-09-20 with the folder's own label already English above it, so the line read half and half.
func consequenceFor(class appbackup.BindClass, role stacks.DataPathRole, msg func(key string) string) string {
switch class {
case appbackup.ClassExcluded:
if role == stacks.RoleImport {
return msg("datapath.consequence.import_excluded")
}
return msg("datapath.consequence.excluded")
case appbackup.ClassMandatory, appbackup.ClassOptional:
return msg("datapath.consequence.kept")
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, lang string) []DataPathCard {
msg := func(key string) string { return s.msgLang(lang, key) }
if st == nil || !st.Deployed || (len(st.Meta.DataPaths) == 0 && st.Meta.MediaLibrary == nil) {
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 = s.msgLang(lang, "datapath.free_space", 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. v0.320.0: a source that is not
// on the box now (no drive resolved, or the drive is not present, so FileBrowser does not
// bind it) is TEXT, never a link — a link to a missing source opens an empty or broken view.
src, drive := s.fbSourceForApp(st.Name)
if src != "" && s.drivePresent(drive) {
link = fileBrowserOpenLink(domain, src, dp.Path)
} else {
s.logger.Printf("[WARN] [web] app page %s: folder %q has no FileBrowser source on this box now (drive %q) — shown without a link", st.Name, dp.Path, drive)
}
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, msg),
IsImport: isImport,
Role: dp.Role,
}
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 append(cards, s.mediaFolderCards(st, domain, lang, cards)...)
}
// mediaLibraryFolders are the `media/` subfolders a whole-media app's library uses — the folders internal/mediawire
// wires into it (Sonarr `tv`, Radarr `movies`, the players both). An app the table does not name gets the `media`
// folder itself. A download client's `media_library.folder` (qBittorrent `torrents`) is its one folder.
var mediaLibraryFolders = map[string][]string{
"sonarr": {"tv"},
"radarr": {"movies"},
"plex": {"movies", "tv"},
"jellyfin": {"movies", "tv"},
"emby": {"movies", "tv"},
}
// mediaFolderLabelKeys name each media subfolder for the household. A folder with no key is shown by its name.
var mediaFolderLabelKeys = map[string]string{
"": "datapath.media.root",
"movies": "datapath.media.movies",
"tv": "datapath.media.tv",
"torrents": "datapath.media.downloads",
"transmission": "datapath.media.downloads",
}
// mediaFolderCards lists, for a media app (`media_library:`), each enrolled drive's media folders the app uses
// (`15-media-library.md` §2). A present drive's folder is a link into that drive's FileBrowser source; a drive that
// is not present is text, with one WARN. No consequence line: the bind's class says the folder is not THIS app's
// data (Sonarr/Radarr/the players declare it `excluded`), which is not the sentence „temporary, not backed up" —
// and the per-drive binds are in no classifier at all (`15` §7). An unverified backup promise is worse than none.
// A folder a declared data_path already links to is not listed twice.
func (s *Server) mediaFolderCards(st *stacks.Stack, domain, lang string, have []DataPathCard) []DataPathCard {
spec := st.Meta.MediaLibrary
if spec == nil || s.settings == nil {
return nil
}
subs := mediaLibraryFolders[st.Meta.Slug]
if f := strings.Trim(path.Clean("/"+spec.Folder), "/"); f != "" {
subs = []string{f}
} else if len(subs) == 0 {
subs = []string{""}
}
seen := map[string]bool{}
for _, c := range have {
if c.Link != "" {
seen[c.Link] = true
}
}
var out []DataPathCard
for _, sp := range s.mediaStoragePaths() {
src := fbSourceName(sp)
present := s.drivePresent(sp.Path)
if !present {
s.logger.Printf("[WARN] [web] app page %s: drive %q is not present — its media folders are shown without a link", st.Name, sp.Path)
}
for _, sub := range subs {
label := sub
if k, ok := mediaFolderLabelKeys[sub]; ok {
label = s.msgLang(lang, k)
}
card := DataPathCard{Label: s.msgLang(lang, "datapath.media.row", label, src), Role: stacks.RoleLibrary, IsMedia: true}
if present {
card.Link = fileBrowserOpenLink(domain, src, path.Join("media", sub))
if seen[card.Link] {
continue
}
seen[card.Link] = true
}
out = append(out, card)
}
}
return out
}
// mediaStoragePaths are the drives a media app binds: enrolled, not network, not decommissioned (the same set
// stacks.mediaDrives starts from), sorted by path so the first drive is stable.
func (s *Server) mediaStoragePaths() []settings.StoragePath {
var out []settings.StoragePath
seen := map[string]bool{}
for _, sp := range s.settings.GetStoragePaths() {
p := filepath.Clean(sp.Path)
if sp.Kind == "network" || sp.Decommissioned || sp.Path == "" || seen[p] {
continue
}
seen[p] = true
sp.Path = p
out = append(out, sp)
}
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
return out
}
// drivePresent reports whether a drive is bound into FileBrowser now: the stack manager's present media drives
// (enrolled, mounted, not dead, not safely removed) — the same gates buildFileBrowserPaths applies.
func (s *Server) drivePresent(drive string) bool {
if drive == "" {
return false
}
var present []string
switch {
case s.presentDrivesFn != nil:
present = s.presentDrivesFn()
case s.stackMgr != nil:
present = s.stackMgr.PresentMediaDrives()
}
for _, p := range present {
if filepath.Clean(p) == filepath.Clean(drive) {
return true
}
}
return false
}
// filesButtonLink is the target of the app header's „Fajlok" button: the first folder with the library role, else
// the first declared folder, else (a media app) the first present drive's `media` folder. Only folders that are a
// link count. "" = no button (an app with no folder on the box, e.g. Immich's data in a named volume).
func (s *Server) filesButtonLink(st *stacks.Stack, cards []DataPathCard) string {
for _, c := range cards {
if !c.IsMedia && c.Link != "" && c.Role == stacks.RoleLibrary {
return c.Link
}
}
for _, c := range cards {
if !c.IsMedia && c.Link != "" {
return c.Link
}
}
if st == nil || st.Meta.MediaLibrary == nil || s.settings == nil || s.cfg.Customer.Domain == "" {
return ""
}
for _, sp := range s.mediaStoragePaths() {
if s.drivePresent(sp.Path) {
return fileBrowserOpenLink(s.cfg.Customer.Domain, fbSourceName(sp), "media")
}
}
return ""
}
// 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 {
src, _ := s.fbSourceForApp(stackName)
return src
}
// fbSourceForApp is fbSourceNameForApp plus the drive path it resolved ("" when none).
func (s *Server) fbSourceForApp(stackName string) (source, drive 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
}
return fbSourceName(sp), sp.Path
}
return "", hdd
}
// fbSourceName is a drive's FileBrowser sidebar name: its label, else the mount basename — exactly what
// infra.RenderFileBrowserConfig emits, so a link and the sidebar cannot disagree.
func fbSourceName(sp settings.StoragePath) string {
if sp.Label != "" {
return sp.Label
}
return filepath.Base(sp.Path)
}