v0.320.0: FileBrowser is part of the box again; every app says where its login is; a „Fájlok" button
gates / gates (push) Successful in 50s
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
This commit is contained in:
@@ -1,26 +1,32 @@
|
||||
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 (Fork-2: all catalog apps get the
|
||||
// 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
|
||||
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).
|
||||
@@ -56,7 +62,7 @@ func consequenceFor(class appbackup.BindClass, role stacks.DataPathRole, msg fun
|
||||
// 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 {
|
||||
if st == nil || !st.Deployed || (len(st.Meta.DataPaths) == 0 && st.Meta.MediaLibrary == nil) {
|
||||
return nil
|
||||
}
|
||||
domain := s.cfg.Customer.Domain
|
||||
@@ -88,12 +94,15 @@ func (s *Server) buildDataPathCards(st *stacks.Stack, lang string) []DataPathCar
|
||||
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
|
||||
// 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)
|
||||
}
|
||||
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
|
||||
@@ -106,6 +115,7 @@ func (s *Server) buildDataPathCards(st *stacks.Stack, lang string) []DataPathCar
|
||||
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
|
||||
@@ -115,29 +125,174 @@ func (s *Server) buildDataPathCards(st *stacks.Stack, lang string) []DataPathCar
|
||||
}
|
||||
cards = append(cards, card)
|
||||
}
|
||||
return cards
|
||||
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 ""
|
||||
return "", ""
|
||||
}
|
||||
hdd := appCfg.Env["HDD_PATH"]
|
||||
if hdd == "" {
|
||||
return ""
|
||||
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 fbSourceName(sp), sp.Path
|
||||
}
|
||||
return ""
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user