Files
felhom-controller/controller/internal/web/datapath_card.go
T
admin 7c4a33b6d1
gates / gates (push) Successful in 24s
v0.258.0: the last four Hungarian things an English household met
R-589 the update badge, R-590 the data-folder backup sentence, R-573 the two channel
banners, R-572 two dead helpers. All four were built in Go, which is why neither the
template parity fixtures nor TestI18nEnglishPages could see them; slice 5's LIVE proof
is what found them.

R-590 is the one that matters most: it is a promise about the customer's files. It
said, in Hungarian and under an already-English folder label, that a drop-zone is
temporary and unbacked. The test now asserts the CONSEQUENCE in both languages — and
that the two languages do not produce the same string, which would mean the English
fell back.

R-572 is not what its row said. The row claimed a template renders "vasárnap" on an
English page; measured, NO template and no Go file called pruneLabel or
nextPruneLabel. They were dead func-map entries returning Hungarian, so they are
deleted rather than translated — translating dead code would add machinery with no
reader and a test pinning a fiction. Deletion is fail-loud and that was proven: a
template naming the removed function panics loadTemplates at startup.

Five red-proofs. One of them says something about the gate rather than the code: the
Go-parity gate does not measure localeFuncs keys against the base capture, so the
citation to TestLocaleFuncsHungarianBundleMatchesFuncMap is what carries them — the
test was extended to make that citation true.

MinAgent: 0.131.0 (unchanged). Hungarian byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-20 18:10:53 +02:00

144 lines
5.4 KiB
Go

package web
import (
"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.
// 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 {
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; 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, msg),
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 ""
}