f30a80841e
gates / gates (push) Successful in 50s
FileBrowser (and traefik, cloudflared, samba) has no app.yaml, so Deployed is false. Since v0.315.0 the app page read Deployed alone and showed FileBrowser "not installed", with Install and without its login. A protected infra stack is now installed by definition on the app page (Deployed || Protected, as every other surface already counted): settings/guide/log tabs, Restart only, no install page, no data tab. The Install press would have deployed the infra stack as an app: DeployStack and the deploy API now refuse a protected stack. In the operator state (the box does not know FileBrowser's password - both demo boxes) the login panel offers "Új jelszó beállítása": the file browser's own CLI with the server stopped (spiked on demo-hp: new 200, old 401, users/shares/settings unchanged), verified, stored encrypted, state generated. Every installed app's settings tab has the login panel with exactly one answer: the box's fields, the login the app wrote (InitialCreds), or a sentence chosen by the catalog's new app_info.login_kind (first_visit default, email, none; none behind the family gate says the family sign-in is the login). "Fájlok ↗" opens the library folder, else the first declared folder, else a media app's first present drive's media folder. The data tab lists each drive's media folders; a folder whose drive is not bound into FileBrowser is text, never a link. Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
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 = fileBrowserLink(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 = fileBrowserLink(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 fileBrowserLink(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)
|
|
}
|