299 lines
12 KiB
Go
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)
|
|
}
|